Skip to content
Snippets Groups Projects
Verified Commit 037798e5 authored by Andreas Ellewsen's avatar Andreas Ellewsen
Browse files

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.
parent 7e2c0487
No related branches found
No related tags found
1 merge request!326Notify sponsors of bad emails in sent invites
Pipeline #134005 failed
......@@ -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():
......
......@@ -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
......
File moved
......@@ -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)
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
)
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,
)
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,
)
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
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)
# 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,
),
),
]
......@@ -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
......
......@@ -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
......
......@@ -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(
......
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"],
}
)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment