Repository: cisagov/manage.get.gov

Issue:
Upper case emails in DomainInvitations cause users to be unable to see their domain
### Current Behavior

If a user has an uppercase email in DomainInvitation, then logs into our system for the first time, login.gov gives us a lowercase email and thus they don't get added to their domain. 

### Expected Behavior

The user should be able to access their domain.

### Steps to Reproduce
1. invite a user to manage a domain but use a capital in their email
2. that user logs in
3. that user is unable to see the associated domain


### Environment

Stable

### Additional Context


Some potential options to implement: 
1. Update all existing domain invitations to be lower case and then make sure that the domain invitation email field always converts to lower. 
2. Make sure that the domain invitation email field always converts to lower and then make sure that where we check for domain invitations on login we ignore case when checking for a matching email in that table.

[Screenshot from a user](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1702073741506679).

### Issue Links

_No response_


Discussion:

Resolving patch:
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -28,6 +28,17 @@ class DomainAddUserForm(forms.Form):
 
     email = forms.EmailField(label="Email")
 
+    def clean(self):
+        """clean form data by lowercasing email"""
+        cleaned_data = super().clean()
+
+        # Lowercase the value of the 'email' field
+        email_value = cleaned_data.get("email")
+        if email_value:
+            cleaned_data["email"] = email_value.lower()
+
+        return cleaned_data
+
 
 class DomainNameserverForm(forms.Form):
     """Form for changing nameservers."""
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -101,7 +101,7 @@ def check_domain_invitations_on_login(self):
         """When a user first arrives on the site, we need to retrieve any domain
         invitations that match their email address."""
         for invitation in DomainInvitation.objects.filter(
-            email=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED
+            email__iexact=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED
         ):
             try:
                 invitation.retrieve()


Test patch:
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -654,3 +654,17 @@ def test_identity_verification_with_new_user(self):
         """A new user who's neither transitioned nor invited should
         return True when tested with class method needs_identity_verification"""
         self.assertTrue(User.needs_identity_verification(self.user.email, self.user.username))
+
+    def test_check_domain_invitations_on_login_caps_email(self):
+        """A DomainInvitation with an email address with capital letters should match
+        a User record whose email address is not in caps"""
+        # create DomainInvitation with CAPS email that matches User email
+        # on a case-insensitive match
+        caps_email = "MAYOR@igorville.gov"
+        # mock the domain invitation save routine
+        with patch("registrar.models.DomainInvitation.save") as save_mock:
+            DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain)
+            self.user.check_domain_invitations_on_login()
+            # if check_domain_invitations_on_login properly matches exactly one
+            # Domain Invitation, then save routine should be called exactly once
+            save_mock.assert_called_once()
diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -1355,29 +1355,55 @@ def test_domain_invitation_created(self):
         out the boto3 SES email sending here.
         """
         # make sure there is no user with this email
-        EMAIL = "mayor@igorville.gov"
-        User.objects.filter(email=EMAIL).delete()
+        email_address = "mayor@igorville.gov"
+        User.objects.filter(email=email_address).delete()
 
         self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
 
         add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
         session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
-        add_page.form["email"] = EMAIL
+        add_page.form["email"] = email_address
         self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
         success_result = add_page.form.submit()
         self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
         success_page = success_result.follow()
 
-        self.assertContains(success_page, EMAIL)
+        self.assertContains(success_page, email_address)
         self.assertContains(success_page, "Cancel")  # link to cancel invitation
-        self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists())
+        self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists())
+
+    @boto3_mocking.patching
+    def test_domain_invitation_created_for_caps_email(self):
+        """Add user on a nonexistent email with CAPS creates an invitation to lowercase email.
+
+        Adding a non-existent user sends an email as a side-effect, so mock
+        out the boto3 SES email sending here.
+        """
+        # make sure there is no user with this email
+        email_address = "mayor@igorville.gov"
+        caps_email_address = "MAYOR@igorville.gov"
+        User.objects.filter(email=email_address).delete()
+
+        self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
+
+        add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        add_page.form["email"] = caps_email_address
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        success_result = add_page.form.submit()
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+        success_page = success_result.follow()
+
+        self.assertContains(success_page, email_address)
+        self.assertContains(success_page, "Cancel")  # link to cancel invitation
+        self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists())
 
     @boto3_mocking.patching
     def test_domain_invitation_email_sent(self):
         """Inviting a non-existent user sends them an email."""
         # make sure there is no user with this email
-        EMAIL = "mayor@igorville.gov"
-        User.objects.filter(email=EMAIL).delete()
+        email_address = "mayor@igorville.gov"
+        User.objects.filter(email=email_address).delete()
 
         self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
 
@@ -1386,28 +1412,28 @@ def test_domain_invitation_email_sent(self):
         with boto3_mocking.clients.handler_for("sesv2", mock_client):
             add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
             session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
-            add_page.form["email"] = EMAIL
+            add_page.form["email"] = email_address
             self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
             add_page.form.submit()
         # check the mock instance to see if `send_email` was called right
         mock_client_instance.send_email.assert_called_once_with(
             FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
-            Destination={"ToAddresses": [EMAIL]},
+            Destination={"ToAddresses": [email_address]},
             Content=ANY,
         )
 
     def test_domain_invitation_cancel(self):
         """Posting to the delete view deletes an invitation."""
-        EMAIL = "mayor@igorville.gov"
-        invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=EMAIL)
+        email_address = "mayor@igorville.gov"
+        invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
         self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
         with self.assertRaises(DomainInvitation.DoesNotExist):
             DomainInvitation.objects.get(id=invitation.id)
 
     def test_domain_invitation_cancel_no_permissions(self):
         """Posting to the delete view as a different user should fail."""
-        EMAIL = "mayor@igorville.gov"
-        invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=EMAIL)
+        email_address = "mayor@igorville.gov"
+        invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
 
         other_user = User()
         other_user.save()
@@ -1419,20 +1445,20 @@ def test_domain_invitation_cancel_no_permissions(self):
     @boto3_mocking.patching
     def test_domain_invitation_flow(self):
         """Send an invitation to a new user, log in and load the dashboard."""
-        EMAIL = "mayor@igorville.gov"
-        User.objects.filter(email=EMAIL).delete()
+        email_address = "mayor@igorville.gov"
+        User.objects.filter(email=email_address).delete()
 
         add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
 
         self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
 
         session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
-        add_page.form["email"] = EMAIL
+        add_page.form["email"] = email_address
         self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
         add_page.form.submit()
 
         # user was invited, create them
-        new_user = User.objects.create(username=EMAIL, email=EMAIL)
+        new_user = User.objects.create(username=email_address, email=email_address)
         # log them in to `self.app`
         self.app.set_user(new_user.username)
         # and manually call the on each login callback