Repository: cdriehuys/django-simple-email-auth

Issue:
Add endpoint to send a verification email
As a consumer of the API, I would like to be able to resend a verification email if I lose the original verification token.

### Acceptance

1. Sending a `POST` request to `/email-verification-requests/` with an unverified but registered email should create a new verification token and send it to the specified email address.
2. If the email address does not exist in the system, an email should be sent to the user indicating that they have not yet registered that email address.
3. If the email address has already been verified, a message indicating the address has already been verified should be sent to the user.

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fe7855a..1ec9e5e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -14,6 +14,8 @@ Features
   ``EMAIL_VERIFICATION_URL`` settings, respectively.
 * :issue:`36`: Add endpoint to verify an email address to the provided REST
   interface.
+* :issue:`38`: Add endpoint to resend a verification email to the provided REST
+  interface.
 
 ******
 v0.3.1
diff --git a/docs/installation.rst b/docs/installation.rst
index 237c2be..e0f1610 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -49,18 +49,23 @@ Add ``email_auth`` to your ``INSTALLED_APPS``:
 
         # Third party apps
 
-        # Core models and templates:
-        "email_auth",
-
         # If you would like to use the provided REST API:
         "email_auth.interfaces.rest",
         "rest_framework",
 
+        # Core models and templates:
+        "email_auth",
+
         # More third party apps
 
         # Your custom apps
     ]
 
+Note the order that you include the apps in is important. In particular,
+``email_auth.interfaces.rest`` overrides some templates provided by the core
+``email_auth``. In order for this to work, the overriding app has to be listed
+first so that Django finds its templates first.
+
 Next ensure Django is `set up to send emails <django-emails_>`_. Additionally,
 ensure ``DEFAULT_FROM_EMAIL`` is set. This is the address that all account
 related emails such as email verifications and password reset emails are sent
diff --git a/email_auth/interfaces/rest/serializers.py b/email_auth/interfaces/rest/serializers.py
index 2674784..c2aefec 100644
--- a/email_auth/interfaces/rest/serializers.py
+++ b/email_auth/interfaces/rest/serializers.py
@@ -1,9 +1,68 @@
+from typing import Optional
+
+import email_utils
+from django.conf import settings
 from django.utils.translation import ugettext_lazy as _
 from rest_framework import serializers
 
 from email_auth import models
 
 
+class EmailVerificationRequestSerializer(serializers.Serializer):
+    """
+    Serializer to request re-sending of a verification email.
+    """
+
+    email = serializers.EmailField()
+
+    def save(self, **kwargs) -> Optional[models.EmailVerification]:
+        """
+        Send the appropriate email notification to the provided email
+        address. If the email address exists in the database but has not
+        been verified, a new verification token is sent. If the email is
+        already verified, a notification informing the user their email
+        is already verified is sent. If the email does not exist in the
+        system, we advice the user to register or add the email address
+        to their account first.
+
+        Returns:
+            The :py:class:`EmailVerification` instance that was created
+            if one was created or else ``None``.
+        """
+        try:
+            email_inst = models.EmailAddress.objects.get(
+                address__iexact=self.validated_data["email"]
+            )
+        except models.EmailAddress.DoesNotExist:
+            self._send_missing_email_notification()
+
+            return None
+
+        if email_inst.is_verified:
+            email_inst.send_already_verified()
+
+            return None
+
+        verification = models.EmailVerification(email=email_inst)
+        verification.send_email()
+
+        return verification
+
+    def _send_missing_email_notification(self):
+        """
+        Send an email to the provided address informing the user they
+        need to register or add the email to their account before they
+        can verify it.
+        """
+        email_utils.send_email(
+            context={"email": self.validated_data["email"]},
+            from_email=settings.DEFAULT_FROM_EMAIL,
+            recipient_list=[self.validated_data["email"]],
+            subject=_("Unregistered Email Address"),
+            template_name="email_auth/emails/unregistered-email",
+        )
+
+
 class EmailVerificationSerializer(serializers.Serializer):
     """
     Serializer used to verify email addresses using verification tokens.
diff --git a/email_auth/interfaces/rest/urls.py b/email_auth/interfaces/rest/urls.py
index 44bc544..ed2fcda 100644
--- a/email_auth/interfaces/rest/urls.py
+++ b/email_auth/interfaces/rest/urls.py
@@ -6,9 +6,14 @@ from email_auth.interfaces.rest import views
 app_name = "email-auth:rest"
 
 urlpatterns = [
+    path(
+        "email-verification-requests/",
+        views.EmailVerificationRequestView.as_view(),
+        name="email-verification-request-create",
+    ),
     path(
         "email-verifications/",
         views.EmailVerificationView.as_view(),
         name="email-verification-create",
-    )
+    ),
 ]
diff --git a/email_auth/interfaces/rest/views.py b/email_auth/interfaces/rest/views.py
index 1b4380a..5a29da0 100644
--- a/email_auth/interfaces/rest/views.py
+++ b/email_auth/interfaces/rest/views.py
@@ -3,6 +3,25 @@ from rest_framework import generics
 from email_auth.interfaces.rest import serializers
 
 
+class EmailVerificationRequestView(generics.CreateAPIView):
+    """
+    post:
+    Request a new verification token be sent to the provided email
+    address.
+
+    If the provided email exists in the system but has not been verified
+    yet, a new verification token will be generated and sent. If the
+    email address does not exist in the system or has already been
+    verified, an email describing the situation will be sent instead. In
+    any of these cases, a `201` status code is returned.
+
+    If the provided email address is not a valid email, a `400` response
+    is returned.
+    """
+
+    serializer_class = serializers.EmailVerificationRequestSerializer
+
+
 class EmailVerificationView(generics.CreateAPIView):
     """
     post:
diff --git a/email_auth/templates/email_auth/emails/unregistered-email.txt b/email_auth/templates/email_auth/emails/unregistered-email.txt
new file mode 100644
index 0000000..95b4f30
--- /dev/null
+++ b/email_auth/templates/email_auth/emails/unregistered-email.txt
@@ -0,0 +1,6 @@
+{% load i18n %}{% blocktrans %}Hello,
+
+Before you can verify ownership of "{{ email }}", you need to add the address
+to your account. If you do not have an account, you can register using this
+email address.
+{% endblocktrans %}


Test patch:
diff --git a/email_auth/conftest.py b/email_auth/conftest.py
index bdcee5e..39ce0c9 100644
--- a/email_auth/conftest.py
+++ b/email_auth/conftest.py
@@ -5,6 +5,15 @@ import pytest
 from email_auth import models
 
 
+@pytest.fixture
+def mock_email_address_qs():
+    mock_qs = mock.Mock(spec=models.EmailAddress.objects)
+    mock_qs.all.return_value = mock_qs
+
+    with mock.patch("email_auth.models.EmailAddress.objects", new=mock_qs):
+        yield mock_qs
+
+
 @pytest.fixture
 def mock_email_verification_qs():
     mock_qs = mock.Mock(spec=models.EmailVerification.objects)
diff --git a/email_auth/interfaces/rest/test/serializers/test_email_verification_request_serializer.py b/email_auth/interfaces/rest/test/serializers/test_email_verification_request_serializer.py
new file mode 100644
index 0000000..d0f399b
--- /dev/null
+++ b/email_auth/interfaces/rest/test/serializers/test_email_verification_request_serializer.py
@@ -0,0 +1,86 @@
+from unittest import mock
+
+import pytest
+from django.conf import settings
+
+from email_auth import models
+
+
+pytest.importorskip("rest_framework")
+
+# Imports that require "rest_framework"
+from email_auth.interfaces.rest import serializers  # noqa
+
+
+@mock.patch("email_auth.models.EmailVerification.send_email")
+def test_save_unverified_email(_, mock_email_address_qs):
+    """
+    Saving the serializer with an address that exists in the database
+    but has not yet been verified should send a new verification email
+    to the provided address.
+    """
+    email = models.EmailAddress(address="test@example.com", is_verified=False)
+    mock_email_address_qs.get.return_value = email
+
+    data = {"email": email.address}
+    serializer = serializers.EmailVerificationRequestSerializer(data=data)
+
+    assert serializer.is_valid()
+    verification = serializer.save()
+
+    assert mock_email_address_qs.get.call_args[1] == {
+        "address__iexact": email.address
+    }
+    assert verification.email == email
+    assert verification.send_email.call_count == 1
+
+
+@mock.patch("email_auth.models.EmailAddress.send_already_verified")
+def test_save_verified_email(_, mock_email_address_qs):
+    """
+    Saving the serializer with an email address that has already been
+    verified should send the user a notification that their email has
+    already been verified.
+    """
+    email = models.EmailAddress(address="test@example.com", is_verified=True)
+    mock_email_address_qs.get.return_value = email
+
+    data = {"email": email.address}
+    serializer = serializers.EmailVerificationRequestSerializer(data=data)
+
+    assert serializer.is_valid()
+    verification = serializer.save()
+
+    assert mock_email_address_qs.get.call_args[1] == {
+        "address__iexact": email.address
+    }
+    assert verification is None
+    assert email.send_already_verified.call_count == 1
+
+
+@mock.patch("email_auth.interfaces.rest.serializers.email_utils.send_email")
+def test_save_missing_email(mock_send_email, mock_email_address_qs):
+    """
+    If the provided email doesn't exist in the database, an email should
+    be sent telling the user to register or add the email address to
+    their account.
+    """
+    mock_email_address_qs.get.side_effect = models.EmailAddress.DoesNotExist
+
+    data = {"email": "test@example.com"}
+    serializer = serializers.EmailVerificationRequestSerializer(data=data)
+
+    assert serializer.is_valid()
+    verification = serializer.save()
+
+    assert mock_email_address_qs.get.call_args[1] == {
+        "address__iexact": data["email"]
+    }
+    assert verification is None
+    assert mock_send_email.call_args[1] == {
+        "context": {"email": data["email"]},
+        "from_email": settings.DEFAULT_FROM_EMAIL,
+        "recipient_list": [data["email"]],
+        "subject": "Unregistered Email Address",
+        "template_name": "email_auth/emails/unregistered-email",
+    }
diff --git a/email_auth/interfaces/rest/test/views/test_email_verification_request_view.py b/email_auth/interfaces/rest/test/views/test_email_verification_request_view.py
new file mode 100644
index 0000000..5ac6ec1
--- /dev/null
+++ b/email_auth/interfaces/rest/test/views/test_email_verification_request_view.py
@@ -0,0 +1,97 @@
+import pytest
+import requests
+from django.contrib.auth import get_user_model
+
+from email_auth import models
+
+
+pytest.importorskip("rest_framework")
+
+# Imports that require "rest_framework"
+from email_auth.interfaces.rest import views, serializers  # noqa
+
+
+def test_get_serializer_class():
+    """
+    Test the serializer class used by the view.
+    """
+    view = views.EmailVerificationRequestView()
+    expected = serializers.EmailVerificationRequestSerializer
+
+    assert view.get_serializer_class() == expected
+
+
+@pytest.mark.functional_test
+def test_request_verification_email(live_server, mailoutbox, settings):
+    """
+    Sending a ``POST`` request to the endpoint with an unverified email
+    address should send an email with a new verification token to the
+    user.
+    """
+    url_template = "https://localhost/verify-email/{key}"
+    settings.EMAIL_AUTH = {"EMAIL_VERIFICATION_URL": url_template}
+
+    user = get_user_model().objects.create_user(username="test-user")
+    email = models.EmailAddress.objects.create(
+        address="test@example.com", user=user
+    )
+
+    data = {"email": email.address}
+    url = f"{live_server}/rest/email-verification-requests/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == data
+    assert len(mailoutbox) == 1
+
+    msg = mailoutbox[0]
+
+    assert msg.to == [email.address]
+    assert url_template.format(key=email.verifications.get().token) in msg.body
+
+
+@pytest.mark.functional_test
+def test_request_verification_email_already_verified(live_server, mailoutbox):
+    """
+    Sending a ``POST`` request to the endpoint with a verified email
+    address should send an email notifying the user the email is already
+    registered.
+    """
+    user = get_user_model().objects.create_user(username="test-user")
+    email = models.EmailAddress.objects.create(
+        address="test@example.com", is_verified=True, user=user
+    )
+
+    data = {"email": email.address}
+    url = f"{live_server}/rest/email-verification-requests/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == data
+    assert len(mailoutbox) == 1
+
+    msg = mailoutbox[0]
+
+    assert msg.to == [email.address]
+    assert "already been verified" in msg.subject.lower()
+
+
+@pytest.mark.functional_test
+def test_request_verification_email_unknown_address(live_server, mailoutbox):
+    """
+    Sending a ``POST`` request to the endpoint with an unknown email
+    address should send an email notifying the user their email address
+    has not been registered yet.
+    """
+    data = {"email": "unknown@example.com"}
+    url = f"{live_server}/rest/email-verification-requests/"
+    response = requests.post(url, data)
+
+    assert response.status_code == 201
+    assert response.json() == data
+    assert len(mailoutbox) == 1
+
+    msg = mailoutbox[0]
+
+    assert msg.to == [data["email"]]
+    assert "unregistered email" in msg.subject.lower()
diff --git a/test_settings.py b/test_settings.py
index e53b755..84bbea9 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -7,8 +7,8 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 INSTALLED_APPS = [
     "django.contrib.auth",
     "django.contrib.contenttypes",
-    "email_auth",
     "email_auth.interfaces.rest",
+    "email_auth",
 ]
 
 SECRET_KEY = "secret"