diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 82bb1a4fc..0bd1a9fbf 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -639,6 +639,21 @@ class DomainApplicationAdmin(ListHeaderAdmin):
return super().change_view(request, object_id, form_url, extra_context)
+class TransitionDomainAdmin(ListHeaderAdmin):
+ """Custom transition domain admin class."""
+
+ # Columns
+ list_display = [
+ "username",
+ "domain_name",
+ "status",
+ "email_sent",
+ ]
+
+ search_fields = ["username", "domain_name"]
+ search_help_text = "Search by user or domain name."
+
+
class DomainInformationInline(admin.StackedInline):
"""Edit a domain information on the domain page.
We had issues inheriting from both StackedInline
@@ -819,4 +834,4 @@ admin.site.register(models.Nameserver, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.DomainApplication, DomainApplicationAdmin)
-admin.site.register(models.TransitionDomain, AuditedAdmin)
+admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
diff --git a/src/registrar/management/commands/generate_test_transition_domains.py b/src/registrar/management/commands/generate_test_transition_domains.py
new file mode 100644
index 000000000..20aba2c58
--- /dev/null
+++ b/src/registrar/management/commands/generate_test_transition_domains.py
@@ -0,0 +1,65 @@
+"""Data migration: Generate fake transition domains, replacing existing ones."""
+
+import logging
+
+from django.core.management import BaseCommand
+from registrar.models import TransitionDomain, Domain
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Generate test transition domains from existing domains"
+
+ # Generates test transition domains for testing send_domain_invitations script.
+ # Running this script removes all existing transition domains, so use with caution.
+ # Transition domains are created with email addresses provided as command line
+ # argument. Email addresses for testing are passed as comma delimited list of
+ # email addresses, and are required to be provided. Email addresses from the list
+ # are assigned to transition domains at time of creation.
+
+ def add_arguments(self, parser):
+ """Add command line arguments."""
+ parser.add_argument(
+ "-e",
+ "--emails",
+ required=True,
+ dest="emails",
+ help="Comma-delimited list of email addresses to be used for testing",
+ )
+
+ def handle(self, **options):
+ """Delete existing TransitionDomains. Generate test ones.
+ expects options[emails]; emails will be assigned to transition
+ domains at the time of creation"""
+
+ # split options[emails] into an array of test emails
+ test_emails = options["emails"].split(",")
+
+ if len(test_emails) > 0:
+ # set up test data
+ self.delete_test_transition_domains()
+ self.load_test_transition_domains(test_emails)
+ else:
+ logger.error("list of emails for testing is required")
+
+ def load_test_transition_domains(self, test_emails: list):
+ """Load test transition domains"""
+
+ # counter for test_emails index
+ test_emails_counter = 0
+ # Need to get actual domain names from the database for this test
+ real_domains = Domain.objects.all()
+ for real_domain in real_domains:
+ TransitionDomain.objects.create(
+ username=test_emails[test_emails_counter % len(test_emails)],
+ domain_name=real_domain.name,
+ status="created",
+ email_sent=False,
+ )
+ test_emails_counter += 1
+
+ def delete_test_transition_domains(self):
+ self.transition_domains = TransitionDomain.objects.all()
+ for transition_domain in self.transition_domains:
+ transition_domain.delete()
diff --git a/src/registrar/management/commands/send_domain_invitations.py b/src/registrar/management/commands/send_domain_invitations.py
new file mode 100644
index 000000000..994013254
--- /dev/null
+++ b/src/registrar/management/commands/send_domain_invitations.py
@@ -0,0 +1,142 @@
+"""Data migration: Send domain invitations once to existing customers."""
+
+import logging
+import copy
+
+from django.core.management import BaseCommand
+from registrar.models import TransitionDomain
+from ...utility.email import send_templated_email, EmailSendingError
+from typing import List
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Send domain invitations once to existing customers."
+
+ # this array is used to store and process the transition_domains
+ transition_domains: List[str] = []
+ # this array is used to store domains with errors, which are not
+ # sent emails; this array is used to update the succesful
+ # transition_domains to email_sent=True, and also to report
+ # out errors
+ domains_with_errors: List[str] = []
+ # this array is used to store email_context; each item in the array
+ # contains the context for a single email; single emails may be 1
+ # or more transition_domains, as they are grouped by username
+ emails_to_send: List[str] = []
+
+ def add_arguments(self, parser):
+ """Add command line arguments."""
+ parser.add_argument(
+ "-s",
+ "--send_emails",
+ action="store_true",
+ default=False,
+ dest="send_emails",
+ help="Send emails ",
+ )
+
+ def handle(self, **options):
+ """Process the objects in TransitionDomain."""
+
+ logger.info("checking domains and preparing emails")
+ # Get all TransitionDomain objects
+ self.transition_domains = TransitionDomain.objects.filter(
+ email_sent=False,
+ ).order_by("username")
+
+ self.build_emails_to_send_array()
+
+ if options["send_emails"]:
+ logger.info("about to send emails")
+ self.send_emails()
+ logger.info("done sending emails")
+
+ self.update_domains_as_sent()
+
+ logger.info("done sending emails and updating transition_domains")
+ else:
+ logger.info("not sending emails")
+
+ def build_emails_to_send_array(self):
+ """this method sends emails to distinct usernames"""
+
+ # data structure to hold email context for a single email;
+ # transition_domains ordered by username, a single email_context
+ # may include information from more than one transition_domain
+ email_context = {"email": ""}
+
+ # loop through all transition_domains; group them by username
+ # into emails_to_send_array
+ for transition_domain in self.transition_domains:
+ # attempt to get the domain from domain objects; if there is
+ # an error getting the domain, skip this domain and add it to
+ # domains_with_errors
+ try:
+ # if prior username does not match current username
+ if (
+ not email_context["email"]
+ or email_context["email"] != transition_domain.username
+ ):
+ # if not first in list of transition_domains
+ if email_context["email"]:
+ # append the email context to the emails_to_send array
+ self.emails_to_send.append(copy.deepcopy(email_context))
+ email_context["domains"] = []
+ email_context["email"] = transition_domain.username
+ email_context["domains"].append(transition_domain.domain_name)
+ except Exception as err:
+ # error condition if domain not in database
+ self.domains_with_errors.append(
+ copy.deepcopy(transition_domain.domain_name)
+ )
+ logger.error(
+ f"error retrieving domain {transition_domain.domain_name}: {err}"
+ )
+ # if there are at least one more transition domains than errors,
+ # then append one more item
+ if len(self.transition_domains) > len(self.domains_with_errors):
+ self.emails_to_send.append(email_context)
+
+ def send_emails(self):
+ if len(self.emails_to_send) > 0:
+ for email_data in self.emails_to_send:
+ self.send_email(email_data)
+ else:
+ logger.info("no emails to send")
+
+ def send_email(self, email_data):
+ try:
+ send_templated_email(
+ "emails/transition_domain_invitation.txt",
+ "emails/transition_domain_invitation_subject.txt",
+ to_address=email_data["email"],
+ context={
+ "domains": email_data["domains"],
+ },
+ )
+ # success message is logged
+ logger.info(
+ f"email sent successfully to {email_data['email']} for "
+ f"{[domain for domain in email_data['domains']]}"
+ )
+ except EmailSendingError as err:
+ logger.error(
+ f"email did not send successfully to {email_data['email']} "
+ f"for {[domain for domain in email_data['domains']]}"
+ f": {err}"
+ )
+ # if email failed to send, set error in domains_with_errors for each
+ # domain in the email so that transition domain email_sent is not set
+ # to True
+ for domain in email_data["domains"]:
+ self.domains_with_errors.append(domain)
+
+ def update_domains_as_sent(self):
+ """set email_sent to True in all transition_domains which have
+ been processed successfully"""
+ for transition_domain in self.transition_domains:
+ if transition_domain.domain_name not in self.domains_with_errors:
+ transition_domain.email_sent = True
+ transition_domain.save()
diff --git a/src/registrar/templates/emails/transition_domain_invitation.txt b/src/registrar/templates/emails/transition_domain_invitation.txt
new file mode 100644
index 000000000..42013dbf7
--- /dev/null
+++ b/src/registrar/templates/emails/transition_domain_invitation.txt
@@ -0,0 +1,29 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+Hi.
+
+You have been added as a manager on {% if domains|length > 1 %}multiple domains (listed below){% else %}{{ domains.0 }}{% endif %}.
+
+YOU NEED A LOGIN.GOV ACCOUNT
+You’ll need a Login.gov account to manage your .gov domain{% if domains|length > 1 %}s{% endif %}. Login.gov provides a simple and secure process for signing into many government services with one account. If you don’t already have one, follow these steps to create your Login.gov account .
+
+DOMAIN MANAGEMENT
+As a .gov domain manager you can add or update information about your domain{% if domains|length > 1 %}s{% endif %}. You’ll also serve as a contact for your .gov domain{% if domains|length > 1 %}s{% endif %}. Please keep your contact information updated. Learn more about domain management .
+{% if domains|length > 1 %}
+DOMAINS
+{% for domain in domains %} {{ domain }}
+{% endfor %}{% else %}
+{% endif %}
+SOMETHING WRONG?
+If you’re not affiliated with {{ domain }} or think you received this message in error, contact the .gov team .
+
+
+THANK YOU
+
+.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
+
+----------------------------------------------------------------
+
+The .gov team
+Contact us:
+Visit
+{% endautoescape %}
diff --git a/src/registrar/templates/emails/transition_domain_invitation_subject.txt b/src/registrar/templates/emails/transition_domain_invitation_subject.txt
new file mode 100644
index 000000000..9302a748e
--- /dev/null
+++ b/src/registrar/templates/emails/transition_domain_invitation_subject.txt
@@ -0,0 +1 @@
+You've been added to a .gov domain
\ No newline at end of file