Skip to content

Commit

Permalink
Merge pull request #411 from cisagov/nmb/confirmation-email
Browse files Browse the repository at this point in the history
Send confirmation email on application submission
  • Loading branch information
neilmb committed Feb 15, 2023
2 parents 44887bf + f21142d commit dd44d40
Show file tree
Hide file tree
Showing 26 changed files with 644 additions and 233 deletions.
28 changes: 28 additions & 0 deletions docs/architecture/decisions/0017-ses-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 17. Use AWS SES for email sending

Date: 2022-02-14

## Status

Approved

## Context

Our application needs to be able to send email to applicants for various
purposes including notifying them that their application has been submitted.
We need infrastructure for programmatically sending email. Amazon Web Services
(AWS) provides the Simple Email Service (SES) that can do that. CISA can
provide access to AWS SES for our application.

## Decision

To use AWS SES to provide programmatic email-sending capability.

## Consequences

We will be dependent on a manual external process to provision and configure
our AWS resources through CISA. We already use external network egress for
Login.gov configuration, so there is no additional network configuration
needed to be able to access the external AWS SES service. We now have two
additional secret credentials to manage in our environment for accessing the
external AWS services.
8 changes: 8 additions & 0 deletions docs/operations/runbooks/rotate_application_secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Where `credentials-<ENVIRONMENT>.json` looks like:
{
"DJANGO_SECRET_KEY": "EXAMPLE",
"DJANGO_SECRET_LOGIN_KEY": "EXAMPLE",
"AWS_ACCESS_KEY_ID": "EXAMPLE",
"AWS_SECRET_ACCESS_KEY": "EXAMPLE",
...
}
```
Expand Down Expand Up @@ -57,3 +59,9 @@ base64 private.pem
```

You also need to upload the `public.crt` key if recently created to the login.gov identity sandbox: https://dashboard.int.identitysandbox.gov/

## AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY

To access the AWS Simple Email Service, we need credentials from the CISA AWS
account for an IAM user who has limited access to only SES. Those credentials
need to be specified in the environment.
3 changes: 3 additions & 0 deletions src/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ cachetools = "*"
requests = "*"
django-fsm = "*"
django-phonenumber-field = {extras = ["phonenumberslite"], version = "*"}
boto3 = "*"

[dev-packages]
django-debug-toolbar = "*"
Expand All @@ -34,3 +35,5 @@ types-requests = "*"
django-stubs = "*"
django-webtest = "*"
types-cachetools = "*"
boto3-mocking = "*"
boto3-stubs = "*"
669 changes: 455 additions & 214 deletions src/Pipfile.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion src/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ def in_domains(domain):
@require_http_methods(["GET"])
@login_required
def available(request, domain=""):

"""Is a given domain available or not.
Response is a JSON dictionary with the key "available" and value true or
Expand Down
3 changes: 3 additions & 0 deletions src/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ services:
# --- These keys are obtained from `.env` file ---
# Set a private JWT signing key for Login.gov
- DJANGO_SECRET_LOGIN_KEY
# AWS credentials
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
stdin_open: true
tty: true
ports:
Expand Down
16 changes: 16 additions & 0 deletions src/registrar/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
from base64 import b64decode
from cfenv import AppEnv # type: ignore
from pathlib import Path
from typing import Final

from botocore.config import Config

# # # ###
# Setup code goes here #
Expand Down Expand Up @@ -49,6 +52,9 @@
secret_login_key = b64decode(secret("DJANGO_SECRET_LOGIN_KEY", ""))
secret_key = secret("DJANGO_SECRET_KEY")

secret_aws_ses_key_id = secret("AWS_ACCESS_KEY_ID", None)
secret_aws_ses_key = secret("AWS_SECRET_ACCESS_KEY", None)


# region: Basic Django Config-----------------------------------------------###

Expand Down Expand Up @@ -213,6 +219,16 @@
# endregion
# region: Email-------------------------------------------------------------###

# Configuration for accessing AWS SES
AWS_ACCESS_KEY_ID = secret_aws_ses_key_id
AWS_SECRET_ACCESS_KEY = secret_aws_ses_key
AWS_REGION = "us-gov-west-1"
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#standard-retry-mode
AWS_RETRY_MODE: Final = "standard"
# base 2 exponential backoff with max of 20 seconds:
AWS_MAX_ATTEMPTS = 3
BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS})

# email address to use for various automated correspondence
# TODO: pick something sensible here
DEFAULT_FROM_EMAIL = "registrar@get.gov"
Expand Down
1 change: 0 additions & 1 deletion src/registrar/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@


class Migration(migrations.Migration):

initial = True

dependencies = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0001_initial"),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0002_domain_host_nameserver_hostip_and_more"),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
(
"registrar",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0004_domainapplication_federal_agency"),
]
Expand Down
1 change: 0 additions & 1 deletion src/registrar/migrations/0006_alter_contact_phone.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0005_domainapplication_city_and_more"),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0006_alter_contact_phone"),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0007_domainapplication_more_organization_information_and_more"),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0008_remove_userprofile_created_at_and_more"),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


class Migration(migrations.Migration):

dependencies = [
("registrar", "0009_domainapplication_federally_recognized_tribe_and_more"),
]
Expand Down
31 changes: 28 additions & 3 deletions src/registrar/models/domain_application.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from __future__ import annotations
from typing import Union

import logging

from django.apps import apps
from django.db import models
from django_fsm import FSMField, transition # type: ignore

from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError


logger = logging.getLogger(__name__)


class DomainApplication(TimeStampedModel):
Expand Down Expand Up @@ -462,6 +468,25 @@ def __str__(self):
except Exception:
return ""

def _send_confirmation_email(self):
"""Send a confirmation email that this application was submitted.
The email goes to the email address that the submitter gave as their
contact information. If there is not submitter information, then do
nothing.
"""
if self.submitter is None or self.submitter.email is None:
logger.warn("Cannot send confirmation email, no submitter email address.")
return
try:
send_templated_email(
"emails/submission_confirmation.txt",
self.submitter.email,
context={"id": self.id, "domain_name": self.requested_domain.name},
)
except EmailSendingError:
logger.warning("Failed to send confirmation email", exc_info=True)

@transition(field="status", source=STARTED, target=SUBMITTED)
def submit(self):
"""Submit an application that is started."""
Expand All @@ -480,9 +505,9 @@ def submit(self):
if not Domain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")

# if no exception was raised, then we don't need to do anything
# inside this method, keep the `pass` here to remind us of that
pass
# When an application is submitted, we need to send a confirmation email
# This is a side-effect of the state transition
self._send_confirmation_email()

# ## Form policies ###
#
Expand Down
1 change: 0 additions & 1 deletion src/registrar/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

@receiver(post_save, sender=User)
def handle_profile(sender, instance, **kwargs):

"""Method for when a User is saved.
If the user is being created, then create a matching UserProfile. Otherwise
Expand Down
4 changes: 4 additions & 0 deletions src/registrar/templates/emails/submission_confirmation.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Thank you for submitting an application for the domain name "{{ domain_name }}".

If you need to make changes to your application, visit
<http://registrar.get.gov/application/{{ id }}/edit>.
9 changes: 9 additions & 0 deletions src/registrar/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import logging

from contextlib import contextmanager
from unittest.mock import Mock
from typing import List, Dict

from django.conf import settings
from django.contrib.auth import get_user_model, login
Expand Down Expand Up @@ -73,3 +75,10 @@ def __call__(self, request):

response = self.get_response(request)
return response


class MockSESClient(Mock):
EMAILS_SENT: List[Dict] = []

def send_email(self, *args, **kwargs):
self.EMAILS_SENT.append({"args": args, "kwargs": kwargs})
37 changes: 36 additions & 1 deletion src/registrar/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
from registrar.models import Contact, DomainApplication, User, Website, Domain
from unittest import skip

import boto3_mocking # type: ignore
from .common import MockSESClient, less_console_noise

boto3_mocking.clients.register_handler("sesv2", MockSESClient)


# The DomainApplication submit method has a side effect of sending an email
# with AWS SES, so mock that out in all of these test cases
@boto3_mocking.patching
class TestDomainApplication(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain application."""
Expand Down Expand Up @@ -61,9 +69,36 @@ def test_status_fsm_submit_succeed(self):
application = DomainApplication.objects.create(
creator=user, requested_domain=site
)
application.submit()
# no submitter email so this emits a log warning
with less_console_noise():
application.submit()
self.assertEqual(application.status, application.SUBMITTED)

def test_submit_sends_email(self):
"""Create an application and submit it and see if email was sent."""
user, _ = User.objects.get_or_create()
contact = Contact.objects.create(email="test@test.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
requested_domain=domain,
submitter=contact,
)
application.save()
application.submit()

# check to see if an email was sent
self.assertGreater(
len(
[
email
for email in MockSESClient.EMAILS_SENT
if "test@test.gov" in email["kwargs"]["Destination"]["ToAddresses"]
]
),
0,
)


class TestDomain(TestCase):
def test_empty_create_fails(self):
Expand Down
3 changes: 3 additions & 0 deletions src/registrar/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from django.contrib.auth import get_user_model

from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore


from registrar.models import DomainApplication, Domain, Contact, Website
from registrar.views.application import ApplicationWizard, Step
Expand Down Expand Up @@ -115,6 +117,7 @@ def test_application_form_empty_submit(self):
"What kind of U.S.-based government organization do you represent?", result
)

@boto3_mocking.patching
def test_application_form_submission(self):
"""Can fill out the entire form and submit.
As we add additional form pages, we need to include them here to make
Expand Down

0 comments on commit dd44d40

Please sign in to comment.