diff --git a/src/registrar/templates/emails/portfolio_removal.txt b/src/registrar/templates/emails/portfolio_removal.txt
new file mode 100644
index 000000000..6de2190ae
--- /dev/null
+++ b/src/registrar/templates/emails/portfolio_removal.txt
@@ -0,0 +1,24 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
+
+{{ requestor_email }} has removed you from {{ portfolio.organization_name }}.
+
+You can no longer view this organization or its related domains within the .gov registrar.
+
+
+SOMETHING WRONG?
+If you have questions or concerns, reach out to the person who removed you from the
+organization, or reply to this email.
+
+
+THANK YOU
+.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
+
+----------------------------------------------------------------
+
+The .gov team
+Contact us:
+Learn about .gov
+
+The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA)
+{% endautoescape %}
diff --git a/src/registrar/templates/emails/portfolio_removal_subject.txt b/src/registrar/templates/emails/portfolio_removal_subject.txt
new file mode 100644
index 000000000..d60ef9859
--- /dev/null
+++ b/src/registrar/templates/emails/portfolio_removal_subject.txt
@@ -0,0 +1 @@
+You've been removed from a .gov organization
\ No newline at end of file
diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py
index 20ac4a565..f07e2f2a7 100644
--- a/src/registrar/tests/test_email_invitations.py
+++ b/src/registrar/tests/test_email_invitations.py
@@ -16,6 +16,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
+ send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
@@ -962,3 +963,76 @@ class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
+
+
+class TestSendPortfolioMemberPermissionRemoveEmail(unittest.TestCase):
+ """Unit tests for send_portfolio_member_permission_remove_email function."""
+
+ @patch("registrar.utility.email_invitations.send_templated_email")
+ @patch("registrar.utility.email_invitations._get_requestor_email")
+ def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
+ """Test that the email is sent successfully when there are no errors."""
+ # Mock data
+ requestor = MagicMock()
+ permissions = MagicMock(spec=UserPortfolioPermission)
+ permissions.user.email = "user@example.com"
+ permissions.portfolio.organization_name = "Test Portfolio"
+
+ mock_get_requestor_email.return_value = "requestor@example.com"
+
+ # Call function
+ result = send_portfolio_member_permission_remove_email(requestor, permissions)
+
+ # Assertions
+ mock_get_requestor_email.assert_called_once_with(requestor, portfolio=permissions.portfolio)
+ mock_send_email.assert_called_once_with(
+ "emails/portfolio_removal.txt",
+ "emails/portfolio_removal_subject.txt",
+ to_address="user@example.com",
+ context={
+ "requested_user": permissions.user,
+ "portfolio": permissions.portfolio,
+ "requestor_email": "requestor@example.com",
+ },
+ )
+ self.assertTrue(result)
+
+ @patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
+ @patch("registrar.utility.email_invitations._get_requestor_email")
+ @patch("registrar.utility.email_invitations.logger")
+ def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
+ """Test that the function returns False and logs an error when email sending fails."""
+ # Mock data
+ requestor = MagicMock()
+ permissions = MagicMock(spec=UserPortfolioPermission)
+ permissions.user.email = "user@example.com"
+ permissions.portfolio.organization_name = "Test Portfolio"
+
+ mock_get_requestor_email.return_value = "requestor@example.com"
+
+ # Call function
+ result = send_portfolio_member_permission_remove_email(requestor, permissions)
+
+ # Assertions
+ mock_logger.warning.assert_called_once_with(
+ "Could not send email organization member removal notification to %s for portfolio: %s",
+ permissions.user.email,
+ permissions.portfolio.organization_name,
+ exc_info=True,
+ )
+ self.assertFalse(result)
+
+ @patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
+ @patch("registrar.utility.email_invitations.logger")
+ def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
+ """Test that an exception in retrieving requestor email is logged."""
+ # Mock data
+ requestor = MagicMock()
+ permissions = MagicMock(spec=UserPortfolioPermission)
+
+ # Call function
+ with self.assertRaises(Exception):
+ send_portfolio_member_permission_remove_email(requestor, permissions)
+
+ # Assertions
+ mock_logger.warning.assert_not_called() # Function should fail before logging email failure
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 65e0350ee..329e8e9f1 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -1669,7 +1669,8 @@ class TestPortfolioMemberDeleteView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_delete_view_members_table_active_requests(self, send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_delete_view_members_table_active_requests(self, send_member_removal, send_removal_emails):
"""Error state w/ deleting a member with active request on Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@@ -1709,12 +1710,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
+ # assert that send_portfolio_member_permission_remove_email is not called
+ send_member_removal.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_delete_view_members_table_only_admin(self, send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_delete_view_members_table_only_admin(self, send_member_removal, send_removal_emails):
"""Error state w/ deleting a member that's the only admin on Members Table"""
# I'm a user with admin permission
@@ -1744,12 +1748,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
+ # assert that send_portfolio_member_permission_remove_email is not called
+ send_member_removal.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_table_delete_member_success(self, mock_send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_table_delete_member_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
# I'm a user
@@ -1774,6 +1781,9 @@ class TestPortfolioMemberDeleteView(WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
+ # Member removal email sent successfully
+ send_member_removal.return_value = True
+
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
User, "is_only_admin_of_portfolio", return_value=False
@@ -1796,12 +1806,23 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
# because member being removed is not an admin
mock_send_removal_emails.assert_not_called()
+ # assert that send_portfolio_member_permission_remove_email is called
+ send_member_removal.assert_called_once()
+
+ # Get the arguments passed to send_portfolio_member_permission_remove_email
+ _, called_kwargs = send_member_removal.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["requestor"], self.user)
+ self.assertEqual(called_kwargs["permissions"].user, upp.user)
+ self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_table_delete_admin_success(self, mock_send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_table_delete_admin_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent."""
@@ -1828,6 +1849,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = True
+ send_member_removal.return_value = True
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@@ -1850,6 +1872,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
+ # assert that send_portfolio_member_permission_remove_email is called
+ send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@@ -1859,13 +1883,23 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
+ # Get the arguments passed to send_portfolio_member_permission_remove_email
+ _, called_kwargs = send_member_removal.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["requestor"], self.user)
+ self.assertEqual(called_kwargs["permissions"].user, upp.user)
+ self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
- def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, mock_send_removal_emails):
+ @patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
+ def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
- not only admin. Because admin, removal emails are sent, but fail to send."""
+ not only admin. Because admin, removal emails are sent, but fail to send.
+ Email to removed member also fails to send."""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@@ -1890,6 +1924,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = False
+ send_member_removal.return_value = False
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@@ -1912,6 +1947,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
+ # assert that send_portfolio_member_permission_remove_email is called
+ send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@@ -1920,6 +1957,15 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["email"], member_email)
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
+
+ # Get the arguments passed to send_portfolio_member_permission_remove_email
+ _, called_kwargs = send_member_removal.call_args
+
+ # Assert the email content
+ self.assertEqual(called_kwargs["requestor"], self.user)
+ self.assertEqual(called_kwargs["permissions"].user, upp.user)
+ self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py
index d206bf279..2ddf74cc0 100644
--- a/src/registrar/utility/email_invitations.py
+++ b/src/registrar/utility/email_invitations.py
@@ -268,6 +268,47 @@ def send_portfolio_member_permission_update_email(requestor, permissions: UserPo
return True
+def send_portfolio_member_permission_remove_email(requestor, permissions: UserPortfolioPermission):
+ """
+ Sends an email notification to a portfolio member when their permissions are deleted.
+
+ This function retrieves the requestor's email and sends a templated email to the affected user,
+ notifying them of the removal of their portfolio permissions.
+
+ Args:
+ requestor (User): The user initiating the permission update.
+ permissions (UserPortfolioPermission): The updated permissions object containing the affected user
+ and the portfolio details.
+
+ Returns:
+ bool: True if the email was sent successfully, False if an EmailSendingError occurred.
+
+ Raises:
+ MissingEmailError: If the requestor has no email associated with their account.
+ """
+ requestor_email = _get_requestor_email(requestor, portfolio=permissions.portfolio)
+ try:
+ send_templated_email(
+ "emails/portfolio_removal.txt",
+ "emails/portfolio_removal_subject.txt",
+ to_address=permissions.user.email,
+ context={
+ "requested_user": permissions.user,
+ "portfolio": permissions.portfolio,
+ "requestor_email": requestor_email,
+ },
+ )
+ except EmailSendingError:
+ logger.warning(
+ "Could not send email organization member removal notification to %s " "for portfolio: %s",
+ permissions.user.email,
+ permissions.portfolio.organization_name,
+ exc_info=True,
+ )
+ return False
+ return True
+
+
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index d63b5964e..3a1124898 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -20,6 +20,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
+ send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
from registrar.utility.errors import MissingEmailError
@@ -149,18 +150,23 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
- # if member being removed is an admin
- if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
- try:
+ try:
+ # if member being removed is an admin
+ if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
# attempt to send notification emails of the removal to other portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_member_permission.user.email,
requestor=request.user,
portfolio=portfolio_member_permission.portfolio,
):
- messages.warning(self.request, "Could not send email notification to existing organization admins.")
- except Exception as e:
- self._handle_exceptions(e)
+ messages.warning(request, "Could not send email notification to existing organization admins.")
+ # send notification email to member being removed
+ if not send_portfolio_member_permission_remove_email(
+ requestor=request.user, permissions=portfolio_member_permission
+ ):
+ messages.warning(request, f"Could not send email notification to {member.email}")
+ except Exception as e:
+ self._handle_exceptions(e)
# passed all error conditions
portfolio_member_permission.delete()