handle multiple domains in email notifications

This commit is contained in:
Rachid Mrad 2025-01-08 18:26:56 -05:00
parent 3d237ba0f5
commit b048ff96de
No known key found for this signature in database
6 changed files with 119 additions and 67 deletions

View file

@ -1480,8 +1480,9 @@ class DomainInvitationAdmin(ListHeaderAdmin):
send_domain_invitation_email( send_domain_invitation_email(
email=requested_email, email=requested_email,
requestor=requestor, requestor=requestor,
domain=domain, domains=domain,
is_member_of_different_org=member_of_a_different_org, is_member_of_different_org=member_of_a_different_org,
requested_user=requested_user
) )
if requested_user is not None: if requested_user is not None:
# Domain Invitation creation for an existing User # Domain Invitation creation for an existing User

View file

@ -1,36 +1,46 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi. {% if requested_user and requested_user.first_name %}
Hi, {{ requested_user.first_name }}.
{% else %}
Hi,
{% endif %}
{{ requestor_email }} has added you as a manager on {{ domain.name }}. {{ requestor_email }} has invited you to manage:
{% for domain in domains %}
{{ domain.name }}
{% endfor %}
You can manage this domain on the .gov registrar <https://manage.get.gov>. To manage domain information, visit the .gov registrar <https://manage.get.gov>.
---------------------------------------------------------------- ----------------------------------------------------------------
{% if not requested_user %}
YOU NEED A LOGIN.GOV ACCOUNT YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to manage your .gov domain. Login.gov provides Youll need a Login.gov account to access the .gov registrar. That account needs to be
a simple and secure process for signing in to many government services with one associated with the following email address: {{ invitee_email_address }}
account.
If you dont already have one, follow these steps to create your Login.gov provides a simple and secure process for signing in to many government
Login.gov account <https://login.gov/help/get-started/create-your-account/>. services with one account. If you dont already have one, follow these steps to create
your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
{% endif %}
DOMAIN MANAGEMENT DOMAIN MANAGEMENT
As a .gov domain manager, you can add or update information about your domain. As a .gov domain manager, you can add or update information like name servers. Youll
Youll also serve as a contact for your .gov domain. Please keep your contact also serve as a contact for the domains you manage. Please keep your contact
information updated. information updated.
Learn more about domain management <https://get.gov/help/domain-management>. Learn more about domain management <https://get.gov/help/domain-management>.
SOMETHING WRONG? SOMETHING WRONG?
If youre not affiliated with {{ domain.name }} or think you received this If youre not affiliated with the .gov domains mentioned in this invitation or think you
message in error, reply to this email. received this message in error, reply to this email.
THANK YOU THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. .Gov helps the public identify official, trusted information. Thank you for using a .gov
domain.
---------------------------------------------------------------- ----------------------------------------------------------------
@ -38,5 +48,6 @@ The .gov team
Contact us: <https://get.gov/contact/> Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov> Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/> The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %} {% endautoescape %}

View file

@ -1 +1 @@
Youve been added to a .gov domain You've been invited to manage {% if domains|length > 1 %}.gov domains{% else %}{{ domains.0.name }}{% endif %}

View file

@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from registrar.models import DomainInvitation from registrar.models import DomainInvitation
from registrar.models.domain import Domain
from registrar.models.user import User
from registrar.utility.errors import ( from registrar.utility.errors import (
AlreadyDomainInvitedError, AlreadyDomainInvitedError,
AlreadyDomainManagerError, AlreadyDomainManagerError,
@ -13,7 +15,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def send_domain_invitation_email(email: str, requestor, domain, is_member_of_different_org): def send_domain_invitation_email(email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None):
""" """
Sends a domain invitation email to the specified address. Sends a domain invitation email to the specified address.
@ -22,8 +24,9 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
Args: Args:
email (str): Email address of the recipient. email (str): Email address of the recipient.
requestor (User): The user initiating the invitation. requestor (User): The user initiating the invitation.
domain (Domain): The domain object for which the invitation is being sent. domains (Domain or list of Domain): The domain objects for which the invitation is being sent.
is_member_of_different_org (bool): if an email belongs to a different org is_member_of_different_org (bool): if an email belongs to a different org
requested_user (User | None): The recipient if the email belongs to a user in the registrar
Raises: Raises:
MissingEmailError: If the requestor has no email associated with their account. MissingEmailError: If the requestor has no email associated with their account.
@ -32,13 +35,19 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
OutsideOrgMemberError: If the requested_user is part of a different organization. OutsideOrgMemberError: If the requested_user is part of a different organization.
EmailSendingError: If there is an error while sending the email. EmailSendingError: If there is an error while sending the email.
""" """
print('send_domain_invitation_email')
# Normalize domains
if isinstance(domains, Domain):
domains = [domains]
# Default email address for staff # Default email address for staff
requestor_email = settings.DEFAULT_FROM_EMAIL requestor_email = settings.DEFAULT_FROM_EMAIL
# Check if the requestor is staff and has an email # Check if the requestor is staff and has an email
if not requestor.is_staff: if not requestor.is_staff:
if not requestor.email or requestor.email.strip() == "": if not requestor.email or requestor.email.strip() == "":
raise MissingEmailError(email=email, domain=domain) domain_names = ", ".join([domain.name for domain in domains])
raise MissingEmailError(email=email, domain=domain_names)
else: else:
requestor_email = requestor.email requestor_email = requestor.email
@ -51,18 +60,19 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
): ):
raise OutsideOrgMemberError raise OutsideOrgMemberError
# Check for an existing invitation # Check for an existing invitation for each domain
try: for domain in domains:
invite = DomainInvitation.objects.get(email=email, domain=domain) try:
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: invite = DomainInvitation.objects.get(email=email, domain=domain)
raise AlreadyDomainManagerError(email) if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: raise AlreadyDomainManagerError(email)
invite.update_cancellation_status() elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
invite.save() invite.update_cancellation_status()
else: invite.save()
raise AlreadyDomainInvitedError(email) else:
except DomainInvitation.DoesNotExist: raise AlreadyDomainInvitedError(email)
pass except DomainInvitation.DoesNotExist:
pass
# Send the email # Send the email
try: try:
@ -71,12 +81,18 @@ def send_domain_invitation_email(email: str, requestor, domain, is_member_of_dif
"emails/domain_invitation_subject.txt", "emails/domain_invitation_subject.txt",
to_address=email, to_address=email,
context={ context={
"domain": domain, "domains": domains,
"requestor_email": requestor_email, "requestor_email": requestor_email,
"invitee_email_address": email,
"requested_user": requested_user,
}, },
) )
except EmailSendingError as err: except EmailSendingError as err:
raise EmailSendingError(f"Could not send email invitation to {email} for domain {domain}.") from err print('point of failure test')
domain_names = ", ".join([domain.name for domain in domains])
raise EmailSendingError(
f"Could not send email invitation to {email} for domains: {domain_names}"
) from err
def send_portfolio_invitation_email(email: str, requestor, portfolio): def send_portfolio_invitation_email(email: str, requestor, portfolio):

View file

@ -1204,7 +1204,7 @@ class DomainAddUserView(DomainFormBaseView):
send_domain_invitation_email( send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domain=self.object, domains=self.object,
is_member_of_different_org=member_of_different_org, is_member_of_different_org=member_of_different_org,
) )
DomainInvitation.objects.get_or_create(email=email, domain=self.object) DomainInvitation.objects.get_or_create(email=email, domain=self.object)
@ -1215,8 +1215,9 @@ class DomainAddUserView(DomainFormBaseView):
send_domain_invitation_email( send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domain=self.object, domains=self.object,
is_member_of_different_org=member_of_different_org, is_member_of_different_org=member_of_different_org,
requested_user=requested_user,
) )
UserDomainRole.objects.create( UserDomainRole.objects.create(
user=requested_user, user=requested_user,

View file

@ -8,13 +8,14 @@ from django.utils.safestring import mark_safe
from django.contrib import messages from django.contrib import messages
from registrar.forms import portfolio as portfolioForms from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User from registrar.models import Portfolio, User
from registrar.models.domain import Domain
from registrar.models.domain_invitation import DomainInvitation from registrar.models.domain_invitation import DomainInvitation
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError from registrar.utility.email import EmailSendingError
from registrar.utility.email_invitations import send_portfolio_invitation_email from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
from registrar.utility.errors import MissingEmailError from registrar.utility.errors import MissingEmailError
from registrar.utility.enums import DefaultUserValues from registrar.utility.enums import DefaultUserValues
from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.mixins import PortfolioMemberPermission
@ -33,6 +34,8 @@ from django.views.generic import View
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.db import IntegrityError from django.db import IntegrityError
from registrar.views.utility.portfolio_helper import get_org_membership
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -237,6 +240,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
removed_domains = request.POST.get("removed_domains") removed_domains = request.POST.get("removed_domains")
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_permission.user member = portfolio_permission.user
portfolio = portfolio_permission.portfolio
added_domain_ids = self._parse_domain_ids(added_domains, "added domains") added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None: if added_domain_ids is None:
@ -248,7 +252,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
if added_domain_ids or removed_domain_ids: if added_domain_ids or removed_domain_ids:
try: try:
self._process_added_domains(added_domain_ids, member) self._process_added_domains(added_domain_ids, member, request.user, portfolio)
self._process_removed_domains(removed_domain_ids, member) self._process_removed_domains(removed_domain_ids, member)
messages.success(request, "The domain assignment changes have been saved.") messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("member-domains", kwargs={"pk": pk})) return redirect(reverse("member-domains", kwargs={"pk": pk}))
@ -263,7 +267,7 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
except Exception as e: except Exception as e:
messages.error( messages.error(
request, request,
"An unexpected error occurred: {str(e)}. If the issue persists, " f"An unexpected error occurred: {str(e)}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.", f"please contact {DefaultUserValues.HELP_EMAIL}.",
) )
logger.error(f"An unexpected error occurred: {str(e)}") logger.error(f"An unexpected error occurred: {str(e)}")
@ -287,16 +291,26 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
logger.error(f"Invalid data for {domain_type}") logger.error(f"Invalid data for {domain_type}")
return None return None
def _process_added_domains(self, added_domain_ids, member): def _process_added_domains(self, added_domain_ids, member, requestor, portfolio):
""" """
Processes added domains by bulk creating UserDomainRole instances. Processes added domains by bulk creating UserDomainRole instances.
""" """
if added_domain_ids: if added_domain_ids:
print('_process_added_domains')
added_domains = Domain.objects.filter(id__in=added_domain_ids)
member_of_a_different_org, _ = get_org_membership(portfolio, member.email, member)
send_domain_invitation_email(
email=member.email,
requestor=requestor,
domains=added_domains,
is_member_of_different_org=member_of_a_different_org,
requested_user=member,
)
# Bulk create UserDomainRole instances for added domains # Bulk create UserDomainRole instances for added domains
UserDomainRole.objects.bulk_create( UserDomainRole.objects.bulk_create(
[ [
UserDomainRole(domain_id=domain_id, user=member, role=UserDomainRole.Roles.MANAGER) UserDomainRole(domain=domain, user=member, role=UserDomainRole.Roles.MANAGER)
for domain_id in added_domain_ids for domain in added_domains
], ],
ignore_conflicts=True, # Avoid duplicate entries ignore_conflicts=True, # Avoid duplicate entries
) )
@ -443,6 +457,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
removed_domains = request.POST.get("removed_domains") removed_domains = request.POST.get("removed_domains")
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
email = portfolio_invitation.email email = portfolio_invitation.email
portfolio = portfolio_invitation.portfolio
added_domain_ids = self._parse_domain_ids(added_domains, "added domains") added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None: if added_domain_ids is None:
@ -454,7 +469,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
if added_domain_ids or removed_domain_ids: if added_domain_ids or removed_domain_ids:
try: try:
self._process_added_domains(added_domain_ids, email) self._process_added_domains(added_domain_ids, email, request.user, portfolio)
self._process_removed_domains(removed_domain_ids, email) self._process_removed_domains(removed_domain_ids, email)
messages.success(request, "The domain assignment changes have been saved.") messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
@ -469,7 +484,7 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
except Exception as e: except Exception as e:
messages.error( messages.error(
request, request,
"An unexpected error occurred: {str(e)}. If the issue persists, " f"An unexpected error occurred: {str(e)}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.", f"please contact {DefaultUserValues.HELP_EMAIL}.",
) )
logger.error(f"An unexpected error occurred: {str(e)}.") logger.error(f"An unexpected error occurred: {str(e)}.")
@ -493,33 +508,40 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
logger.error(f"Invalid data for {domain_type}.") logger.error(f"Invalid data for {domain_type}.")
return None return None
def _process_added_domains(self, added_domain_ids, email): def _process_added_domains(self, added_domain_ids, email, requestor, portfolio):
""" """
Processes added domain invitations by updating existing invitations Processes added domain invitations by updating existing invitations
or creating new ones. or creating new ones.
""" """
if not added_domain_ids: if added_domain_ids:
return added_domains = Domain.objects.filter(id__in=added_domain_ids)
member_of_a_different_org, _ = get_org_membership(portfolio, email, None)
# Update existing invitations from CANCELED to INVITED send_domain_invitation_email(
existing_invitations = DomainInvitation.objects.filter(domain_id__in=added_domain_ids, email=email)
existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED)
# Determine which domains need new invitations
existing_domain_ids = existing_invitations.values_list("domain_id", flat=True)
new_domain_ids = set(added_domain_ids) - set(existing_domain_ids)
# Bulk create new invitations
DomainInvitation.objects.bulk_create(
[
DomainInvitation(
domain_id=domain_id,
email=email, email=email,
status=DomainInvitation.DomainInvitationStatus.INVITED, requestor=requestor,
domains=added_domains,
is_member_of_different_org=member_of_a_different_org,
) )
for domain_id in new_domain_ids
] # Update existing invitations from CANCELED to INVITED
) existing_invitations = DomainInvitation.objects.filter(domain__in=added_domains, email=email)
existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED)
# Determine which domains need new invitations
existing_domain_ids = existing_invitations.values_list("domain_id", flat=True)
new_domain_ids = set(added_domain_ids) - set(existing_domain_ids)
# Bulk create new invitations
DomainInvitation.objects.bulk_create(
[
DomainInvitation(
domain_id=domain_id,
email=email,
status=DomainInvitation.DomainInvitationStatus.INVITED,
)
for domain_id in new_domain_ids
]
)
def _process_removed_domains(self, removed_domain_ids, email): def _process_removed_domains(self, removed_domain_ids, email):
""" """
@ -755,8 +777,9 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
if not requested_user or not permission_exists: if not requested_user or not permission_exists:
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
portfolio_invitation = form.save() portfolio_invitation = form.save()
portfolio_invitation.retrieve() if requested_user is not None:
portfolio_invitation.save() portfolio_invitation.retrieve()
portfolio_invitation.save()
messages.success(self.request, f"{requested_email} has been invited.") messages.success(self.request, f"{requested_email} has been invited.")
else: else:
if permission_exists: if permission_exists: