From 037798e5f269d388182a19d6589ce1d423760f24 Mon Sep 17 00:00:00 2001
From: Andreas Ellewsen <andreas@ellewsen.no>
Date: Fri, 13 May 2022 14:25:04 +0200
Subject: [PATCH] Notify sponsors of bad emails in sent invites

If the sponsor writes a valid but non-existent email in a guest invite
we retried sending this email indefinitely. Instead of leaving django q
in an endless loop, we now send an email to the sponsor warning about
the problem instead.
---
 greg/tasks.py                                 |  15 +-
 greg/tests/test_tasks.py                      |  15 +-
 gregui/api/views/{__init__,py => __init__.py} |   0
 gregui/api/views/invitation.py                |  28 ++--
 gregui/mailutils.py                           | 129 ------------------
 gregui/mailutils/__init__.py                  |   0
 gregui/mailutils/confirm_guest.py             |  48 +++++++
 gregui/mailutils/invite_guest.py              | 102 ++++++++++++++
 gregui/mailutils/protocol.py                  |  20 +++
 gregui/mailutils/role_ending.py               |  20 +++
 .../0004_add_email_template_type.py           |  27 ++++
 gregui/models.py                              |   3 +
 gregui/tests/api/views/test_invite_guest.py   |  27 ++--
 gregui/tests/conftest.py                      |  10 ++
 gregui/tests/test_mailutils.py                | 113 ++++++++++-----
 15 files changed, 343 insertions(+), 214 deletions(-)
 rename gregui/api/views/{__init__,py => __init__.py} (100%)
 delete mode 100644 gregui/mailutils.py
 create mode 100644 gregui/mailutils/__init__.py
 create mode 100644 gregui/mailutils/confirm_guest.py
 create mode 100644 gregui/mailutils/invite_guest.py
 create mode 100644 gregui/mailutils/protocol.py
 create mode 100644 gregui/mailutils/role_ending.py
 create mode 100644 gregui/migrations/0004_add_email_template_type.py

diff --git a/greg/tasks.py b/greg/tasks.py
index 9e751c9f..59a5c03e 100644
--- a/greg/tasks.py
+++ b/greg/tasks.py
@@ -9,10 +9,10 @@ from django.conf import settings
 
 from greg.importers.orgreg import OrgregImporter
 from greg.models import Role, Sponsor
-from gregui import mailutils
+from gregui.mailutils.role_ending import RolesEnding
 
 
-def notify_sponsors_roles_ending() -> None:
+def notify_sponsors_roles_ending() -> list[str]:
     """
     This task notifies sponsors of roles (that are accessible to them)
     that are about to expire.
@@ -51,11 +51,16 @@ def notify_sponsors_roles_ending() -> None:
                 sp2roles[sp].append(role)
 
     # Send emails to sponsors
+    remindermailer = RolesEnding()
+    task_ids = []
     for sp, roles in sp2roles.items():
-        mailutils.send_role_ending_mail(
-            mail_to=sponsors.get(id=sp).work_email,  # type: ignore
-            num_roles=len(roles),
+        task_ids.append(
+            remindermailer.queue_mail(
+                mail_to=sponsors.get(id=sp).work_email,  # type: ignore
+                num_roles=len(roles),
+            )
         )
+    return task_ids
 
 
 def import_from_orgreg():
diff --git a/greg/tests/test_tasks.py b/greg/tests/test_tasks.py
index 31d975e2..13bad3ed 100644
--- a/greg/tests/test_tasks.py
+++ b/greg/tests/test_tasks.py
@@ -31,19 +31,8 @@ def test_notify_sponsors_roles_ending(
     role_end_reminder_template, role_person_foo2, sponsor_org_unit
 ):
     mail.outbox = []
-    notify_sponsors_roles_ending()
-    assert len(mail.outbox) == 1
-    assert mail.outbox[0].to == ["sponsor_guy@example.com"]
-    assert (
-        mail.outbox[0].body
-        == """Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
-Du administrerer 1 gjesteroller som snart utløper.
-Dersom det er ønskelig å forlenge rollene kan dette gjøres i webgrensesnittet.
-
-This message has been automatically generated by the guest registration system.
-You can maintan 1 guest roles about to expire.
-To extend the guest role please use the web interface."""
-    )
+    task_ids = notify_sponsors_roles_ending()
+    assert len(task_ids) == 1
 
 
 @pytest.fixture
diff --git a/gregui/api/views/__init__,py b/gregui/api/views/__init__.py
similarity index 100%
rename from gregui/api/views/__init__,py
rename to gregui/api/views/__init__.py
diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py
index 1d4fb1ee..47343090 100644
--- a/gregui/api/views/invitation.py
+++ b/gregui/api/views/invitation.py
@@ -23,7 +23,8 @@ from greg.permissions import IsSponsor
 from greg.utils import get_default_invitation_expire_date_from_now
 from gregui.api.serializers.guest import GuestRegisterSerializer
 from gregui.api.serializers.invitation import InviteGuestSerializer
-from gregui.mailutils import send_confirmation_mail_from_link, send_invite_mail
+from gregui.mailutils.confirm_guest import ConfirmGuest
+from gregui.mailutils.invite_guest import InviteGuest
 from gregui.models import GregUserProfile
 
 logger = structlog.getLogger(__name__)
@@ -77,20 +78,8 @@ class InvitationView(CreateAPIView, DestroyAPIView):
             invitation__role__person_id=person.id,
             invitation__role__sponsor_id=sponsor_user.sponsor_id,
         ):
-            try:
-                send_invite_mail(invitationlink)
-            except Exception:
-                logger.exception("send_invite_mail_failed", exc_info=True)
-                # The invite has been created, so send 201, but include some data saying the
-                # e-mail was not sent properly
-                return Response(
-                    status=status.HTTP_201_CREATED,
-                    data={
-                        "code": "invite_email_failed",
-                        "message": "Failed to send invite e-mail",
-                    },
-                )
-
+            invitemailer = InviteGuest()
+            invitemailer.queue_mail(invitationlink)
         # Empty json-body to avoid client complaining about non-json data in response
         return Response(status=status.HTTP_201_CREATED, data={})
 
@@ -353,7 +342,8 @@ class InvitedGuestView(GenericAPIView):
             invite_link.save()
 
             # Send an email to the sponsor
-            send_confirmation_mail_from_link(invite_link)
+            confirmmailer = ConfirmGuest()
+            confirmmailer.send_confirmation_mail_from_link(invite_link)
         return Response(status=status.HTTP_200_OK)
 
     def _verify_only_allowed_updates_in_request(
@@ -477,7 +467,7 @@ class ResendInvitationView(UpdateModelMixin, APIView):
             return Response(status=status.HTTP_400_BAD_REQUEST)
 
         non_expired_links = invitation_links.filter(expire__gt=timezone.now())
-
+        invitemailer = InviteGuest()
         if non_expired_links.count() > 0:
             if non_expired_links.count() > 1:
                 # Do not expect this to happen
@@ -485,7 +475,7 @@ class ResendInvitationView(UpdateModelMixin, APIView):
 
             # Just resend all and do not create a new one
             for link in non_expired_links:
-                send_invite_mail(link)
+                invitemailer.queue_mail(link)
         else:
             # All the invitation links have expired, create a new one
             invitations_to_resend = set(
@@ -503,6 +493,6 @@ class ResendInvitationView(UpdateModelMixin, APIView):
                     invitation=invitation,
                     expire=get_default_invitation_expire_date_from_now(),
                 )
-                send_invite_mail(invitation_link)
+                invitemailer.queue_mail(invitation_link)
 
         return Response(status=status.HTTP_200_OK)
diff --git a/gregui/mailutils.py b/gregui/mailutils.py
deleted file mode 100644
index 83c8dc65..00000000
--- a/gregui/mailutils.py
+++ /dev/null
@@ -1,129 +0,0 @@
-import logging
-
-from typing import Optional, Union
-from django.conf import settings
-from django.template.context import Context
-from django_q.tasks import async_task
-
-from greg.models import InvitationLink
-from gregui.models import EmailTemplate
-
-logger = logging.getLogger(__name__)
-
-
-def prepare_arguments(
-    template: EmailTemplate, context: dict[str, str], mail_to: str
-) -> dict[str, Union[str, list[str]]]:
-    """Combine input to a dict ready for use as arguments ti django's send_mail"""
-    return {
-        "subject": template.get_subject(context),
-        "message": template.get_body(context),
-        "from_email": template.from_email or None,
-        "recipient_list": [mail_to],
-    }
-
-
-def make_registration_url(token: str) -> str:
-    return "{base}/invite#{token}".format(
-        base=settings.BASE_URL,
-        token=token,
-    )
-
-
-def make_guest_profile_url(person_id: Union[str, int]) -> str:
-    return "{base}/sponsor/guest/{person_id}".format(
-        base=settings.BASE_URL,
-        person_id=person_id,
-    )
-
-
-def registration_template(
-    institution: str, sponsor: str, mail_to: str, token: str
-) -> dict[str, Union[str, list[str]]]:
-    """
-    Prepare email for registration
-
-    Produces a complete set of arguments ready for use with django.core.mail.send_mail
-    when sending a registration email to the guest.
-    """
-    template = EmailTemplate.objects.get(
-        template_key=EmailTemplate.EmailType.GUEST_REGISTRATION
-    )
-    registration_link = make_registration_url(token)
-    context = Context(
-        {
-            "institution": institution,
-            "sponsor": sponsor,
-            "registration_link": registration_link,
-        }
-    )
-    return prepare_arguments(template, context, mail_to)
-
-
-def confirmation_template(
-    guest_name: str, mail_to: str, guest_person_id: Union[str, int]
-) -> dict[str, Union[str, list[str]]]:
-    """
-    Prepare email for confirmation
-
-    Produces a complete set of arguments ready for use with django.core.mail.send_mail
-    when sending a confirmation email to the sponsor.
-    """
-    template = EmailTemplate.objects.get(
-        template_key=EmailTemplate.EmailType.SPONSOR_CONFIRMATION
-    )
-    confirmation_link = make_guest_profile_url(guest_person_id)
-    context = Context({"guest": guest_name, "confirmation_link": confirmation_link})
-    return prepare_arguments(template, context, mail_to)
-
-
-def reminder_template(mail_to: str, num_roles: int) -> dict[str, Union[str, list[str]]]:
-    template = EmailTemplate.objects.get(
-        template_key=EmailTemplate.EmailType.ROLE_END_REMINDER
-    )
-    context = Context({"num_roles": num_roles})
-    return prepare_arguments(template, context, mail_to)
-
-
-def send_registration_mail(mail_to: str, sponsor: str, token: str) -> str:
-    arguments = registration_template(settings.INSTANCE_NAME, sponsor, mail_to, token)
-    return async_task("django.core.mail.send_mail", **arguments)
-
-
-def send_confirmation_mail(
-    mail_to: str, guest_name: str, guest_person_id: Union[int, str]
-) -> str:
-    arguments = confirmation_template(
-        guest_name=guest_name, mail_to=mail_to, guest_person_id=guest_person_id
-    )
-    return async_task("django.core.mail.send_mail", **arguments)
-
-
-def send_role_ending_mail(mail_to: str, num_roles: int) -> str:
-    arguments = reminder_template(mail_to, num_roles)
-    return async_task("django.core.mail.send_mail", **arguments)
-
-
-def send_invite_mail(link: InvitationLink) -> Optional[str]:
-    email_address = link.invitation.role.person.private_email
-    if not email_address:
-        logger.warning(
-            "No e-mail address found for invitation link with ID: {%s}", link.id
-        )
-        return None
-
-    sponsor = link.invitation.role.sponsor
-    return send_registration_mail(
-        mail_to=email_address.value,
-        sponsor=f"{sponsor.first_name} {sponsor.last_name}",
-        token=link.uuid,
-    )
-
-
-def send_confirmation_mail_from_link(link: InvitationLink) -> Optional[str]:
-    email_address = link.invitation.role.sponsor.work_email
-    guest = link.invitation.role.person
-    guest_name = f"{guest.first_name} {guest.last_name}"
-    return send_confirmation_mail(
-        mail_to=email_address, guest_name=guest_name, guest_person_id=guest.id
-    )
diff --git a/gregui/mailutils/__init__.py b/gregui/mailutils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/gregui/mailutils/confirm_guest.py b/gregui/mailutils/confirm_guest.py
new file mode 100644
index 00000000..e2ff17b0
--- /dev/null
+++ b/gregui/mailutils/confirm_guest.py
@@ -0,0 +1,48 @@
+from typing import Optional, Union
+
+from django.conf import settings
+from django.template.context import Context
+from django_q.tasks import async_task
+
+from greg.models import InvitationLink
+from gregui.mailutils.protocol import prepare_arguments
+from gregui.models import EmailTemplate
+
+
+class ConfirmGuest:
+    def __init__(self):
+        pass
+
+    def _make_guest_profile_url(self, person_id: Union[str, int]) -> str:
+        return "{base}/sponsor/guest/{person_id}".format(
+            base=settings.BASE_URL,
+            person_id=person_id,
+        )
+
+    def get_template(self):
+        return EmailTemplate.objects.get(
+            template_key=EmailTemplate.EmailType.SPONSOR_CONFIRMATION
+        )
+
+    def queue_mail(
+        self, mail_to: str, guest_name: str, guest_person_id: Union[int, str]
+    ) -> str:
+        arguments = prepare_arguments(
+            template=self.get_template(),
+            context=Context(
+                {
+                    "guest": guest_name,
+                    "confirmation_link": self._make_guest_profile_url(guest_person_id),
+                }
+            ),
+            mail_to=mail_to,
+        )
+        return async_task("django.core.mail.send_mail", **arguments)
+
+    def send_confirmation_mail_from_link(self, link: InvitationLink) -> Optional[str]:
+        guest = link.invitation.role.person
+        return self.queue_mail(
+            mail_to=link.invitation.role.sponsor.work_email,
+            guest_name=f"{guest.first_name} {guest.last_name}",
+            guest_person_id=guest.id,
+        )
diff --git a/gregui/mailutils/invite_guest.py b/gregui/mailutils/invite_guest.py
new file mode 100644
index 00000000..c8796420
--- /dev/null
+++ b/gregui/mailutils/invite_guest.py
@@ -0,0 +1,102 @@
+from smtplib import SMTPRecipientsRefused
+from typing import Optional
+
+import structlog
+from django.conf import settings
+from django.core import mail
+from django.template.context import Context
+from django_q.tasks import async_task
+
+from greg.models import InvitationLink, Sponsor
+from gregui.models import EmailTemplate
+
+logger = structlog.getLogger(__name__)
+
+
+class InviteGuest:
+    def make_registration_url(self, token: str) -> str:
+        return "{base}/invite#{token}".format(
+            base=settings.BASE_URL,
+            token=token,
+        )
+
+    def get_template(self):
+        return EmailTemplate.objects.get(
+            template_key=EmailTemplate.EmailType.GUEST_REGISTRATION
+        )
+
+    def get_notice_template(self):
+        return EmailTemplate.objects.get(
+            template_key=EmailTemplate.EmailType.INVALID_EMAIL
+        )
+
+    def send_bad_email_notice(
+        self, guest_name: str, guest_mail: str, sponsor_mail: str
+    ):
+        template = self.get_notice_template()
+        context = Context(
+            {
+                "guest_name": guest_name,
+                "guest_email": guest_mail,
+            }
+        )
+        mail.send_mail(
+            subject=template.get_subject(context),
+            message=template.get_body(context),
+            from_email=template.from_email or None,
+            recipient_list=[sponsor_mail],
+        )
+
+    def queue_mail(self, link: InvitationLink) -> Optional[str]:
+        guest = link.invitation.role.person
+        email_address = guest.private_email
+        if not email_address:
+            logger.warning(
+                "No e-mail address found for invitation link with ID: {%s}", link.id
+            )
+            return None
+        sponsor = link.invitation.role.sponsor
+        return async_task(
+            "gregui.mailutils.invite_guest.try_send_registration_mail",
+            **{
+                "guest_name": f"{guest.first_name} {guest.last_name}",
+                "guest_mail": email_address.value,
+                "sponsor_id": sponsor.id,
+                "sponsor_name": f"{sponsor.first_name} {sponsor.last_name}",
+                "token": link.uuid,
+            },
+        )
+
+
+def try_send_registration_mail(
+    guest_name: str,
+    guest_mail: str,
+    sponsor_id: int,
+    sponsor_name: str,
+    token: str,
+):
+    ig = InviteGuest()
+    template = ig.get_template()
+    context = Context(
+        {
+            "institution": settings.INSTANCE_NAME,
+            "sponsor": sponsor_name,
+            "registration_link": ig.make_registration_url(token),
+        }
+    )
+    try:
+        mail.send_mail(
+            subject=template.get_subject(context),
+            message=template.get_body(context),
+            from_email=template.from_email or None,
+            recipient_list=[guest_mail],
+        )
+    except SMTPRecipientsRefused:
+        sp = Sponsor.objects.get(pk=sponsor_id)
+        # TODO: Mark the invite in the frontend as bad
+        if sp.work_email:
+            ig.send_bad_email_notice(
+                guest_name=guest_name,
+                guest_mail=guest_mail,
+                sponsor_mail=sp.work_email,
+            )
diff --git a/gregui/mailutils/protocol.py b/gregui/mailutils/protocol.py
new file mode 100644
index 00000000..45dd3c2f
--- /dev/null
+++ b/gregui/mailutils/protocol.py
@@ -0,0 +1,20 @@
+from typing import Protocol, Union
+
+from gregui.models import EmailTemplate
+
+
+def prepare_arguments(
+    template: EmailTemplate, context: dict[str, str], mail_to: str
+) -> dict[str, Union[str, list[str]]]:
+    """Combine input to a dict ready for use as arguments to django's send_mail"""
+    return {
+        "subject": template.get_subject(context),
+        "message": template.get_body(context),
+        "from_email": template.from_email or None,
+        "recipient_list": [mail_to],
+    }
+
+
+class EmailSender(Protocol):
+    def queue_mail(self, *args):
+        pass
diff --git a/gregui/mailutils/role_ending.py b/gregui/mailutils/role_ending.py
new file mode 100644
index 00000000..2366cfa1
--- /dev/null
+++ b/gregui/mailutils/role_ending.py
@@ -0,0 +1,20 @@
+from django.template.context import Context
+from django_q.tasks import async_task
+
+from gregui.mailutils.protocol import prepare_arguments
+from gregui.models import EmailTemplate
+
+
+class RolesEnding:
+    def __init__(self):
+        pass
+
+    def queue_mail(self, mail_to: str, num_roles: int) -> str:
+        arguments = prepare_arguments(
+            template=EmailTemplate.objects.get(
+                template_key=EmailTemplate.EmailType.ROLE_END_REMINDER
+            ),
+            context=Context({"num_roles": num_roles}),
+            mail_to=mail_to,
+        )
+        return async_task("django.core.mail.send_mail", **arguments)
diff --git a/gregui/migrations/0004_add_email_template_type.py b/gregui/migrations/0004_add_email_template_type.py
new file mode 100644
index 00000000..4c3c0fce
--- /dev/null
+++ b/gregui/migrations/0004_add_email_template_type.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.0.2 on 2022-05-13 12:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("gregui", "0003_add_email_template_type"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="emailtemplate",
+            name="template_key",
+            field=models.CharField(
+                choices=[
+                    ("guest_registration", "Guest Registration"),
+                    ("sponsor_confirmation", "Sponsor Confirmation"),
+                    ("role_end_reminder", "Role End Reminder"),
+                    ("invalid_email", "Invalid Email"),
+                ],
+                max_length=64,
+                unique=True,
+            ),
+        ),
+    ]
diff --git a/gregui/models.py b/gregui/models.py
index 23de6d77..256f7ba6 100644
--- a/gregui/models.py
+++ b/gregui/models.py
@@ -37,6 +37,8 @@ class EmailTemplate(BaseModel):
     GUEST_REGISTRATION is for informing a guest that they have been invited
     SPONSOR_CONFIRMATION is for informing the sponsor they must verify the guest's information
     ROLE_END_REMINDER is used when reminding the sponsor if their ending roles in the near future
+    INVALID_EMAIL is used to notify sponsors when email sending fails
+    due to a problem with the address of an invited guest
     """
 
     class EmailType(models.TextChoices):
@@ -45,6 +47,7 @@ class EmailTemplate(BaseModel):
         GUEST_REGISTRATION = "guest_registration"
         SPONSOR_CONFIRMATION = "sponsor_confirmation"
         ROLE_END_REMINDER = "role_end_reminder"
+        INVALID_EMAIL = "invalid_email"
 
     template_key = models.CharField(
         max_length=64, choices=EmailType.choices, unique=True
diff --git a/gregui/tests/api/views/test_invite_guest.py b/gregui/tests/api/views/test_invite_guest.py
index 588529ed..51e9f545 100644
--- a/gregui/tests/api/views/test_invite_guest.py
+++ b/gregui/tests/api/views/test_invite_guest.py
@@ -8,12 +8,12 @@ from rest_framework.test import APIRequestFactory, force_authenticate
 
 from greg.models import Identity, Person, Role, Invitation, InvitationLink
 from gregui.api.views.invitation import InvitationView
+from gregui.mailutils.invite_guest import InviteGuest
 
 
 @pytest.mark.django_db
 def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker):
-    # Mock function to avoid exception because there are no e-mail templates in the database
-    mocker.patch("gregui.api.views.invitation.send_invite_mail")
+    send_invite_mock_function = mocker.patch.object(InviteGuest, "queue_mail")
 
     test_comment = "This is a test comment"
     contact_person_unit = "This is a test contact person"
@@ -33,11 +33,12 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker):
             "contact_person_unit": contact_person_unit,
         },
     }
-    url = reverse("gregui-v1:invitation")
 
     assert len(Person.objects.all()) == 0
 
-    request = APIRequestFactory().post(url, data, format="json")
+    request = APIRequestFactory().post(
+        path=reverse("gregui-v1:invitation"), data=data, format="json"
+    )
     force_authenticate(request, user=user_sponsor)
 
     response = InvitationView.as_view()(request)
@@ -62,6 +63,7 @@ def test_invite_guest(client, user_sponsor, unit_foo, role_type_foo, mocker):
     assert role.end_date == role_end_date.date()
     assert role.contact_person_unit == contact_person_unit
     assert role.comments == test_comment
+    send_invite_mock_function.assert_called()
 
 
 @pytest.mark.django_db
@@ -117,9 +119,7 @@ def test_invite_resend_existing_invite_active(
     invitation_link_expired,
     mocker,
 ):
-    send_invite_mock_function = mocker.patch(
-        "gregui.api.views.invitation.send_invite_mail"
-    )
+    send_invite_mock_function = mocker.patch.object(InviteGuest, "queue_mail")
     log_in(user_sponsor)
 
     invitation_links_for_person = InvitationLink.objects.filter(
@@ -154,9 +154,8 @@ def test_invite_resend_existing_invite_active(
 def test_invite_resend_existing_invite_not_active(
     client, log_in, user_sponsor, person_invited, invitation_link_expired, mocker
 ):
-    send_invite_mock_function = mocker.patch(
-        "gregui.api.views.invitation.send_invite_mail", return_value=10
-    )
+    send_invite_mock_function = mocker.patch.object(InviteGuest, "queue_mail")
+
     log_in(user_sponsor)
 
     invitation_links_for_person = InvitationLink.objects.filter(
@@ -205,9 +204,7 @@ def test_invite_resend_person_multiple_links_send_all(
     client, log_in, user_sponsor, invited_person, registration_template, mocker
 ):
     """Resending an invite for a person with multiple working links should resend all of them."""
-    send_invite_mock_function = mocker.patch(
-        "gregui.api.views.invitation.send_invite_mail"
-    )
+    send_invite_mock_function = mocker.patch.object(InviteGuest, "queue_mail")
     log_in(user_sponsor)
     person, invitation = invited_person
     invitation = Invitation.objects.filter(role__person_id=person.id).first()
@@ -236,9 +233,7 @@ def test_invite_resend_person_multiple_expired_links_send_all(
     Resending invites for a person with multiple invitations where all links are
     expired should resend all of them.
     """
-    send_invite_mock_function = mocker.patch(
-        "gregui.api.views.invitation.send_invite_mail"
-    )
+    send_invite_mock_function = mocker.patch.object(InviteGuest, "queue_mail")
 
     log_in(user_sponsor)
     # Expire invitation link
diff --git a/gregui/tests/conftest.py b/gregui/tests/conftest.py
index c7500fd5..b999205c 100644
--- a/gregui/tests/conftest.py
+++ b/gregui/tests/conftest.py
@@ -616,6 +616,16 @@ To complete the registration of your guest account, please follow this link: {{
     return EmailTemplate.objects.get(id=et.id)
 
 
+@pytest.fixture
+def invalid_email_template():
+    et = EmailTemplate.objects.create(
+        template_key=EmailTemplate.EmailType.INVALID_EMAIL,
+        subject="invalid email",
+        body="""Your guest {{guest_name}} has bad email {{guest_email}}""",
+    )
+    return EmailTemplate.objects.get(pk=et.id)
+
+
 @pytest.fixture
 def consent_type_foo() -> ConsentType:
     type_foo = ConsentType.objects.create(
diff --git a/gregui/tests/test_mailutils.py b/gregui/tests/test_mailutils.py
index ce5f66cd..672f738c 100644
--- a/gregui/tests/test_mailutils.py
+++ b/gregui/tests/test_mailutils.py
@@ -1,8 +1,12 @@
+from smtplib import SMTPRecipientsRefused
+from django.conf import settings
 from django.core import mail
-from django_q.tasks import result
+from django.template.context import Context
 
 import pytest
-from gregui import mailutils
+from gregui.mailutils.confirm_guest import ConfirmGuest
+
+from gregui.mailutils.invite_guest import InviteGuest, try_send_registration_mail
 
 
 @pytest.mark.django_db
@@ -12,20 +16,25 @@ def test_registration_template(registration_template):
         "recipient_list": ["test@example.com"],
         "subject": "registration subject",
         "message": """Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
-Du har blitt registrert som gjest på InstanceName av Foo Bar.
-For å fullføre registreringen av gjestekontoen følg denne lenken: https://example.org/invite#secret-key
+Du har blitt registrert som gjest på local av Foo Bar.
+For å fullføre registreringen av gjestekontoen følg denne lenken: Baz
 
 This message has been automatically generated by the guest registration system.
-You have been registered as a guest at InstanceName by Foo Bar.
-To complete the registration of your guest account, please follow this link: https://example.org/invite#secret-key""",
+You have been registered as a guest at local by Foo Bar.
+To complete the registration of your guest account, please follow this link: Baz""",
     }
-    rendered_template = mailutils.registration_template(
-        institution="InstanceName",
-        sponsor="Foo Bar",
-        mail_to="test@example.com",
-        token="secret-key",
+    ig = InviteGuest()
+    template = ig.get_template()
+    context = Context(
+        {
+            "institution": settings.INSTANCE_NAME,
+            "sponsor": "Foo Bar",
+            "registration_link": "Baz",
+        }
     )
-    assert rendered_template == prefilled_template
+    assert template.get_subject(context) == prefilled_template["subject"]
+    assert template.get_body(context) == prefilled_template["message"]
+    assert template.from_email == prefilled_template["from_email"]
 
 
 @pytest.mark.django_db
@@ -35,24 +44,34 @@ def test_confirmation_template(confirmation_template):
         "recipient_list": ["test@example.com"],
         "subject": "confirmation subject",
         "message": """Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
-Din gjest, Foo Bar, har fullført registrering, bekreft gjesten her: https://example.org/sponsor/guest/123
+Din gjest, Foo Bar, har fullført registrering, bekreft gjesten her: Baz
 
 This message has been automatically generated by the guest registration system.
-Your guest, Foo Bar, has completed their registration, please confirm the guest here: https://example.org/sponsor/guest/123""",
+Your guest, Foo Bar, has completed their registration, please confirm the guest here: Baz""",
     }
-    rendered_template = mailutils.confirmation_template(
-        guest_name="Foo Bar", mail_to="test@example.com", guest_person_id=123
+    scg = ConfirmGuest()
+    template = scg.get_template()
+    context = Context(
+        {
+            "guest": "Foo Bar",
+            "confirmation_link": "Baz",
+        }
     )
-    assert rendered_template == prefilled_template
+    assert template.get_subject(context) == prefilled_template["subject"]
+    assert template.get_body(context) == prefilled_template["message"]
+    assert template.from_email == prefilled_template["from_email"]
 
 
 @pytest.mark.django_db
 def test_registration_mail(registration_template):
     mail.outbox = []
-    task_id = mailutils.send_registration_mail(
-        mail_to="test@example.no", sponsor="Foo", token="secret-key"
+    try_send_registration_mail(
+        guest_name="Guest Guesterson",
+        guest_mail="test@example.no",
+        sponsor_id=1,
+        sponsor_name="Foo",
+        token="secret-key",
     )
-    assert result(task_id) == 1
     assert len(mail.outbox) == 1
     assert mail.outbox[0].to == ["test@example.no"]
 
@@ -60,21 +79,20 @@ def test_registration_mail(registration_template):
 @pytest.mark.django_db
 def test_confirmation_mail(confirmation_template):
     mail.outbox = []
-    task_id = mailutils.send_confirmation_mail(
+    scg = ConfirmGuest()
+    task_id = scg.queue_mail(
         mail_to="test@example.no", guest_name="Foo", guest_person_id=123
     )
-    assert result(task_id) == 1
-    assert len(mail.outbox) == 1
-    assert mail.outbox[0].to == ["test@example.no"]
+    assert isinstance(task_id, str)
 
 
 @pytest.mark.django_db
 def test_send_invite_mail(registration_template, invited_person):
     """Verify function queues an email when called"""
     _, link = invited_person
-    assert len(mail.outbox) == 0
-    assert mailutils.send_invite_mail(link)
-    assert len(mail.outbox) == 1
+    ig = InviteGuest()
+    task_id = ig.queue_mail(link)
+    assert isinstance(task_id, str)
 
 
 @pytest.mark.django_db
@@ -82,9 +100,9 @@ def test_send_invite_mail_no_mail(invited_person):
     """Verify function returns None if an email is not present"""
     person, link = invited_person
     person.private_email.delete()
-    assert len(mail.outbox) == 0
-    assert mailutils.send_invite_mail(link) is None
-    assert len(mail.outbox) == 0
+    ig = InviteGuest()
+    task_id = ig.queue_mail(link)
+    assert task_id is None
 
 
 @pytest.mark.django_db
@@ -92,5 +110,36 @@ def test_confirmation_mail_from_link(invited_person, confirmation_template):
     mail.outbox = []
     _, link = invited_person
     assert len(mail.outbox) == 0
-    assert mailutils.send_confirmation_mail_from_link(link)
-    assert len(mail.outbox) == 1
+    scg = ConfirmGuest()
+    task_id = scg.send_confirmation_mail_from_link(link)
+    assert isinstance(task_id, str)
+
+
+@pytest.mark.django_db
+def test_try_send_registration_mail(
+    registration_template, invalid_email_template, mocker, sponsor_foo
+):
+    send_invite_mock_function = mocker.patch("django.core.mail.send_mail")
+
+    def side_effect(*args, **kwargs):
+        if "oladunk@example.com" in kwargs["recipient_list"]:
+            raise SMTPRecipientsRefused(recipients=kwargs["recipient_list"])
+
+    send_invite_mock_function.side_effect = side_effect
+
+    try_send_registration_mail(
+        guest_name="Ola Dunk",
+        guest_mail="oladunk@example.com",
+        sponsor_id=sponsor_foo.id,
+        sponsor_name=sponsor_foo.first_name + " " + sponsor_foo.last_name,
+        token="secret-key",
+    )
+    assert send_invite_mock_function.call_count == 2
+    send_invite_mock_function.assert_called_with(
+        **{
+            "subject": "invalid email",
+            "message": "Your guest Ola Dunk has bad email oladunk@example.com",
+            "from_email": None,
+            "recipient_list": ["foo@example.org"],
+        }
+    )
-- 
GitLab