Repository: cisagov/manage.get.gov

Issue:
Send a daily email with "full domain metadata" report to team inbox
### Story

As an analyst
I need to have registry metadata available to me in case our application/db is unavailable
so that I have the information needed to conduct outreach or assess requests for support.

### Acceptance Criteria

- [ ] Send a daily email to help@get.gov with the "full domain metadata" report attached
- [ ] The report must be zipped and encrypted
  - [ ] Coordinate with @h-m-f-t to generate and store a shared secret in our vault

### Additional Context

Implementation consideration:
- daily email sent to [help@get.gov](mailto:help@get.gov) that can be accessed by analysts
keep it mind that after testing we only want this happening on stable. 
suggest looking at the setup being done for our chron job on current full
### Issue Links
Blocked by #1556 


Discussion:
* Utilize ISPROD=TRUE 
* Confirm with Cameron about maybe sending on dev (he will get emails from us testing) 
* Zander has a chron job that's post current-full etc but it's not sending an email and we want to do the email part
* Factor in a way to turn it off easily bc it could be chaos for staging/stable (turn off the service?)
* Take the metadata file and then password protect that zip file
^ Look into Django related zip-ing and password protect (set as env variable)

@therealslimhsiehdy I'm ok to get emails from dev!
[Working notes for 1727](https://docs.google.com/document/d/1xg6lg1sUh30NjP2TRd3aFH_tRDEZT2ynQBpmVlOYXxU/edit) (let me know if the viewing access is not working)

Resolving patch:
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -74,6 +74,9 @@
 secret_aws_s3_key = secret("secret_access_key", None) or secret("AWS_S3_SECRET_ACCESS_KEY", None)
 secret_aws_s3_bucket_name = secret("bucket", None) or secret("AWS_S3_BUCKET_NAME", None)
 
+# Passphrase for the encrypted metadata email
+secret_encrypt_metadata = secret("SECRET_ENCRYPT_METADATA", None)
+
 secret_registry_cl_id = secret("REGISTRY_CL_ID")
 secret_registry_password = secret("REGISTRY_PASSWORD")
 secret_registry_cert = b64decode(secret("REGISTRY_CERT", ""))
@@ -94,6 +97,7 @@
 
 # Controls production specific feature toggles
 IS_PRODUCTION = env_is_production
+SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
 
 # Applications are modular pieces of code.
 # They are provided by Django, by third-parties, or by yourself.
diff --git a/src/registrar/management/commands/email_current_metadata_report.py b/src/registrar/management/commands/email_current_metadata_report.py
new file mode 100644
--- /dev/null
+++ b/src/registrar/management/commands/email_current_metadata_report.py
@@ -0,0 +1,105 @@
+"""Generates current-metadata.csv then uploads to S3 + sends email"""
+
+import logging
+import os
+import pyzipper
+
+from datetime import datetime
+
+from django.core.management import BaseCommand
+from django.conf import settings
+from registrar.utility import csv_export
+from registrar.utility.s3_bucket import S3ClientHelper
+from ...utility.email import send_templated_email
+
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    help = (
+        "Generates and uploads a domain-metadata.csv file to our S3 bucket "
+        "which is based off of all existing Domains."
+    )
+
+    def add_arguments(self, parser):
+        """Add our two filename arguments."""
+        parser.add_argument("--directory", default="migrationdata", help="Desired directory")
+        parser.add_argument(
+            "--checkpath",
+            default=True,
+            help="Flag that determines if we do a check for os.path.exists. Used for test cases",
+        )
+
+    def handle(self, **options):
+        """Grabs the directory then creates domain-metadata.csv in that directory"""
+        file_name = "domain-metadata.csv"
+        # Ensures a slash is added
+        directory = os.path.join(options.get("directory"), "")
+        check_path = options.get("checkpath")
+
+        logger.info("Generating report...")
+        try:
+            self.email_current_metadata_report(directory, file_name, check_path)
+        except Exception as err:
+            # TODO - #1317: Notify operations when auto report generation fails
+            raise err
+        else:
+            logger.info(f"Success! Created {file_name} and successfully sent out an email!")
+
+    def email_current_metadata_report(self, directory, file_name, check_path):
+        """Creates a current-metadata.csv file under the specified directory,
+        then uploads it to a AWS S3 bucket. This is done for resiliency
+        reasons in the event our application goes down and/or the email
+        cannot send -- we'll still be able to grab info from the S3
+        instance"""
+        s3_client = S3ClientHelper()
+        file_path = os.path.join(directory, file_name)
+
+        # Generate a file locally for upload
+        with open(file_path, "w") as file:
+            csv_export.export_data_type_to_csv(file)
+
+        if check_path and not os.path.exists(file_path):
+            raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
+
+        s3_client.upload_file(file_path, file_name)
+
+        # Set zip file name
+        current_date = datetime.now().strftime("%m%d%Y")
+        current_filename = f"domain-metadata-{current_date}.zip"
+
+        # Pre-set zip file name
+        encrypted_metadata_output = current_filename
+
+        # Set context for the subject
+        current_date_str = datetime.now().strftime("%Y-%m-%d")
+
+        # Encrypt the metadata
+        encrypted_metadata_in_bytes = self._encrypt_metadata(
+            s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA)
+        )
+
+        # Send the metadata file that is zipped
+        send_templated_email(
+            template_name="emails/metadata_body.txt",
+            subject_template_name="emails/metadata_subject.txt",
+            to_address=settings.DEFAULT_FROM_EMAIL,
+            context={"current_date_str": current_date_str},
+            attachment_file=encrypted_metadata_in_bytes,
+        )
+
+    def _encrypt_metadata(self, input_file, output_file, password):
+        """Helper function for encrypting the attachment file"""
+        current_date = datetime.now().strftime("%m%d%Y")
+        current_filename = f"domain-metadata-{current_date}.csv"
+        # Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
+        # We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
+        with pyzipper.AESZipFile(
+            output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
+        ) as f_out:
+            f_out.setpassword(password)
+            f_out.writestr(current_filename, input_file)
+        with open(output_file, "rb") as file_data:
+            attachment_in_bytes = file_data.read()
+        return attachment_in_bytes
diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py
--- a/src/registrar/utility/email.py
+++ b/src/registrar/utility/email.py
@@ -2,8 +2,12 @@
 
 import boto3
 import logging
+from datetime import datetime
 from django.conf import settings
 from django.template.loader import get_template
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
 
 
 logger = logging.getLogger(__name__)
@@ -15,7 +19,14 @@ class EmailSendingError(RuntimeError):
     pass
 
 
-def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}):
+def send_templated_email(
+    template_name: str,
+    subject_template_name: str,
+    to_address: str,
+    bcc_address="",
+    context={},
+    attachment_file: str = None,
+):
     """Send an email built from a template to one email address.
 
     template_name and subject_template_name are relative to the same template
@@ -45,15 +56,50 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr
         destination["BccAddresses"] = [bcc_address]
 
     try:
-        ses_client.send_email(
-            FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
-            Destination=destination,
-            Content={
-                "Simple": {
-                    "Subject": {"Data": subject},
-                    "Body": {"Text": {"Data": email_body}},
+        if attachment_file is None:
+            ses_client.send_email(
+                FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
+                Destination=destination,
+                Content={
+                    "Simple": {
+                        "Subject": {"Data": subject},
+                        "Body": {"Text": {"Data": email_body}},
+                    },
                 },
-            },
-        )
+            )
+        else:
+            ses_client = boto3.client(
+                "ses",
+                region_name=settings.AWS_REGION,
+                aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+                aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+                config=settings.BOTO_CONFIG,
+            )
+            send_email_with_attachment(
+                settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client
+            )
     except Exception as exc:
         raise EmailSendingError("Could not send SES email.") from exc
+
+
+def send_email_with_attachment(sender, recipient, subject, body, attachment_file, ses_client):
+    # Create a multipart/mixed parent container
+    msg = MIMEMultipart("mixed")
+    msg["Subject"] = subject
+    msg["From"] = sender
+    msg["To"] = recipient
+
+    # Add the text part
+    text_part = MIMEText(body, "plain")
+    msg.attach(text_part)
+
+    # Add the attachment part
+    attachment_part = MIMEApplication(attachment_file)
+    # Adding attachment header + filename that the attachment will be called
+    current_date = datetime.now().strftime("%m%d%Y")
+    current_filename = f"domain-metadata-{current_date}.zip"
+    attachment_part.add_header("Content-Disposition", f'attachment; filename="{current_filename}"')
+    msg.attach(attachment_part)
+
+    response = ses_client.send_raw_email(Source=sender, Destinations=[recipient], RawMessage={"Data": msg.as_string()})
+    return response


Test patch:
diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py
--- a/src/registrar/tests/test_emails.py
+++ b/src/registrar/tests/test_emails.py
@@ -5,7 +5,8 @@
 from django.test import TestCase
 from .common import completed_application, less_console_noise
 
-
+from datetime import datetime
+from registrar.utility import email
 import boto3_mocking  # type: ignore
 
 
@@ -182,3 +183,32 @@ def test_submission_confirmation_no_anything_else_spacing(self):
         self.assertNotIn("Anything else", body)
         # spacing should be right between adjacent elements
         self.assertRegex(body, r"5557\n\n----")
+
+    @boto3_mocking.patching
+    def test_send_email_with_attachment(self):
+        with boto3_mocking.clients.handler_for("ses", self.mock_client_class):
+            sender_email = "sender@example.com"
+            recipient_email = "recipient@example.com"
+            subject = "Test Subject"
+            body = "Test Body"
+            attachment_file = b"Attachment file content"
+            current_date = datetime.now().strftime("%m%d%Y")
+            current_filename = f"domain-metadata-{current_date}.zip"
+
+            email.send_email_with_attachment(
+                sender_email, recipient_email, subject, body, attachment_file, self.mock_client
+            )
+            # Assert that the `send_raw_email` method of the mocked SES client was called with the expected params
+            self.mock_client.send_raw_email.assert_called_once()
+
+            # Get the args passed to the `send_raw_email` method
+            call_args = self.mock_client.send_raw_email.call_args[1]
+
+            # Assert that the attachment filename is correct
+            self.assertEqual(call_args["RawMessage"]["Data"].count(f'filename="{current_filename}"'), 1)
+
+            # Assert that the attachment content is encrypted
+            self.assertIn("Content-Type: application/octet-stream", call_args["RawMessage"]["Data"])
+            self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"])
+            self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"])
+            self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"])