Skip to content

Commit

Permalink
Adds email notifications (#786)
Browse files Browse the repository at this point in the history
## Ticket

Resolves navapbc/platform-test#140

## Changes

- Apply defaults to _"sender email"_ and _"reply to email"_
- Propagate `enable_notifications` throughout the configuration as it is
now being used
- Create the following modules:
- `notifications_email_domain`, meant to be deployed from `main`, which
deploys an DNS records in service of validating an email identity
- `existing_notifications_email_domain`, meant be deployed from
temporary environments, which uses data resources to pull from
`notifications_email_domain`
- `notifications`, used in both of the above cases, which deploys an AWS
Pinpoint application capable of sending emails
- Creates notification resources!

## Usage Context

Grants.gov plans to use this for "transactional" emails, eg. password
resets, status updates, etc.

## Context for reviewers

This will be followed up by:

- #789
- #778
- #777

## Testing

See navapbc/platform-test#141, specifically
navapbc/platform-test#141 (comment)
  • Loading branch information
coilysiren authored Dec 3, 2024
1 parent 94b4dd0 commit 1af2dbd
Show file tree
Hide file tree
Showing 24 changed files with 251 additions and 18 deletions.
1 change: 1 addition & 0 deletions infra/app/app-config/dev.tf
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module "dev_config" {
enable_https = false
has_database = local.has_database
has_incident_management_service = local.has_incident_management_service
enable_notifications = local.enable_notifications

# Enable and configure identity provider.
enable_identity_provider = local.enable_identity_provider
Expand Down
16 changes: 9 additions & 7 deletions infra/app/app-config/env-config/notifications.tf
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
# Notifications configuration
locals {
notifications_config = var.enable_notifications ? {
# Set to an SES-verified email address to be used when sending emails.
# Docs: https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html
sender_email = null
# Pinpoint app name.
name = "${var.app_name}-${var.environment}"

# Configure the name that users see in the "From" section of their inbox, so that it's
# clearer who the email is from.
# Configure the name that users see in the "From" section of their inbox,
# so that it's clearer who the email is from.
sender_display_name = null

# Set to the email address to be used when sending emails.
# If enable_notifications is true, this is required.
sender_email = "notifications@${var.domain_name}"

# Configure the REPLY-TO email address if it should be different from the sender.
# Note: Only used by the identity-provider service.
reply_to_email = null
reply_to_email = "notifications@${var.domain_name}"
} : null
}
5 changes: 3 additions & 2 deletions infra/app/app-config/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ locals {
enable_identity_provider = false

# Whether or not the application should deploy a notification service
# Note: This is not yet ready for use.
# TODO(https://github.com/navapbc/template-infra/issues/567)
# If enabled:
# 1. Creates an AWS Pinpoint application
# 2. Configures email notifications using AWS SES
enable_notifications = false

environment_configs = {
Expand Down
4 changes: 4 additions & 0 deletions infra/app/app-config/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ output "enable_identity_provider" {
value = local.enable_identity_provider
}

output "enable_notifications" {
value = local.enable_notifications
}

output "shared_network_name" {
value = local.shared_network_name
}
1 change: 1 addition & 0 deletions infra/app/app-config/prod.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module "prod_config" {
has_database = local.has_database
has_incident_management_service = local.has_incident_management_service
enable_identity_provider = local.enable_identity_provider
enable_notifications = local.enable_notifications

# These numbers are a starting point based on this article
# Update the desired instance size and counts based on the project's specific needs
Expand Down
1 change: 1 addition & 0 deletions infra/app/app-config/staging.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module "staging_config" {
has_database = local.has_database
has_incident_management_service = local.has_incident_management_service
enable_identity_provider = local.enable_identity_provider
enable_notifications = local.enable_notifications

# Enables ECS Exec access for debugging or jump access.
# See https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-exec.html
Expand Down
1 change: 1 addition & 0 deletions infra/app/service/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ module "service" {
BUCKET_NAME = local.storage_config.bucket_name
},
local.identity_provider_environment_variables,
local.notifications_environment_variables,
local.service_config.extra_environment_variables
)

Expand Down
44 changes: 44 additions & 0 deletions infra/app/service/notifications.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
locals {
# If this is a temporary environment, re-use an existing email identity. Otherwise, create a new one.
domain_identity_arn = module.app_config.enable_notifications ? (
!local.is_temporary ?
module.notifications_email_domain[0].domain_identity_arn :
module.existing_notifications_email_domain[0].domain_identity_arn
) : null
notifications_environment_variables = module.app_config.enable_notifications ? {
AWS_PINPOINT_APP_ID = module.notifications[0].app_id,
AWS_PINPOINT_SENDER_EMAIL = local.notifications_config.sender_email
} : {}
notifications_app_name = module.app_config.enable_notifications ? "${local.prefix}${local.notifications_config.name}" : ""
}

# If the app has `enable_notifications` set to true AND this is not a temporary
# environment, then create a email notification identity.
module "notifications_email_domain" {
count = module.app_config.enable_notifications && !local.is_temporary ? 1 : 0
source = "../../modules/notifications-email-domain/resources"

domain_name = local.service_config.domain_name
}

# If the app has `enable_notifications` set to true AND this *is* a temporary
# environment, then create a email notification identity.
module "existing_notifications_email_domain" {
count = module.app_config.enable_notifications && local.is_temporary ? 1 : 0
source = "../../modules/notifications-email-domain/data"

domain_name = local.service_config.domain_name
}

# If the app has `enable_notifications` set to true, create a new email notification
# AWS Pinpoint app for the service. A new app is created for all environments, including
# temporary environments.
module "notifications" {
count = module.app_config.enable_notifications ? 1 : 0
source = "../../modules/notifications/resources"

name = local.notifications_app_name
domain_identity_arn = local.domain_identity_arn
sender_display_name = local.notifications_config.sender_display_name
sender_email = local.notifications_config.sender_email
}
13 changes: 4 additions & 9 deletions infra/modules/identity-provider/resources/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@
## - Configures MFA
############################################################################################

data "aws_ses_email_identity" "sender" {
count = var.sender_email != null ? 1 : 0
email = var.sender_email
}

resource "aws_cognito_user_pool" "main" {
name = var.name

Expand All @@ -34,12 +29,12 @@ resource "aws_cognito_user_pool" "main" {
# Use this SES email to send cognito emails. If we're not using SES for emails then use null.
# Optionally configures the FROM address and the REPLY-TO address.
# Optionally configures using the Cognito default email or using SES.
source_arn = var.sender_email != null ? data.aws_ses_email_identity.sender[0].arn : null
email_sending_account = var.sender_email != null ? "DEVELOPER" : "COGNITO_DEFAULT"
source_arn = var.email_identity_arn
email_sending_account = var.email_identity_arn != null ? "DEVELOPER" : "COGNITO_DEFAULT"
# Customize the name that users see in the "From" section of their inbox, so that it's clearer who the email is from.
# This name also needs to be updated manually in the Cognito console for each environment's Advanced Security emails.
from_email_address = var.sender_email != null ? (var.sender_display_name != null ? "${var.sender_display_name} <${var.sender_email}>" : var.sender_email) : null
reply_to_email_address = var.reply_to_email != null ? var.reply_to_email : null
from_email_address = var.email_identity_arn != null ? (var.sender_display_name != null ? "${var.sender_display_name} <${var.sender_email}>" : var.sender_email) : null
reply_to_email_address = var.reply_to_email
}

password_policy {
Expand Down
6 changes: 6 additions & 0 deletions infra/modules/identity-provider/resources/variables.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
variable "email_identity_arn" {
type = string
description = "The arn of the SESv2 email identity to use to send emails"
default = null
}

variable "is_temporary" {
description = "Whether the service is meant to be spun up temporarily (e.g. for automated infra tests). This is used to disable deletion protection."
type = bool
Expand Down
3 changes: 3 additions & 0 deletions infra/modules/notifications-email-domain/data/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
data "aws_sesv2_email_identity" "main" {
email_identity = var.domain_name
}
3 changes: 3 additions & 0 deletions infra/modules/notifications-email-domain/data/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "domain_identity_arn" {
value = data.aws_sesv2_email_identity.main.arn
}
4 changes: 4 additions & 0 deletions infra/modules/notifications-email-domain/data/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
variable "domain_name" {
type = string
description = "The domain name to use for the email identity"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Allow AWS Pinpoint to send email on behalf of this email identity.
# Docs: https://docs.aws.amazon.com/pinpoint/latest/developerguide/security_iam_id-based-policy-examples.html#security_iam_resource-based-policy-examples-access-ses-identities
resource "aws_sesv2_email_identity_policy" "sender" {
email_identity = aws_sesv2_email_identity.sender_domain.email_identity
policy_name = "PinpointEmail"

policy = jsonencode(
{
Version = "2008-10-17",
Statement = [
{
Sid = "PinpointEmail",
Effect = "Allow",
Principal = {
Service = "pinpoint.amazonaws.com"
},
Action = "ses:*",
Resource = aws_sesv2_email_identity.sender_domain.arn,
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
},
StringLike = {
"aws:SourceArn" = "arn:aws:mobiletargeting:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:apps/*"
}
}
}
]
}
)
}
34 changes: 34 additions & 0 deletions infra/modules/notifications-email-domain/resources/dns.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
data "aws_route53_zone" "zone" {
name = var.domain_name
}

resource "aws_route53_record" "dkim" {
count = 3

allow_overwrite = true
ttl = 60
type = "CNAME"
zone_id = data.aws_route53_zone.zone.zone_id
name = "${aws_sesv2_email_identity.sender_domain.dkim_signing_attributes[0].tokens[count.index]}._domainkey"
records = ["${aws_sesv2_email_identity.sender_domain.dkim_signing_attributes[0].tokens[count.index]}.dkim.amazonses.com"]

depends_on = [aws_sesv2_email_identity.sender_domain]
}

resource "aws_route53_record" "spf_mail_from" {
allow_overwrite = true
ttl = "600"
type = "TXT"
zone_id = data.aws_route53_zone.zone.zone_id
name = aws_sesv2_email_identity_mail_from_attributes.sender_domain.mail_from_domain
records = ["v=spf1 include:amazonses.com ~all"]
}

resource "aws_route53_record" "mx_receive" {
allow_overwrite = true
type = "MX"
ttl = "600"
name = local.mail_from_domain
zone_id = data.aws_route53_zone.zone.zone_id
records = ["10 feedback-smtp.${data.aws_region.current.name}.amazonaws.com"]
}
23 changes: 23 additions & 0 deletions infra/modules/notifications-email-domain/resources/logs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Configures AWS SES to send additional logging to AWS Cloudwatch.
# See https://docs.aws.amazon.com/ses/latest/dg/event-destinations-manage.html
resource "aws_ses_event_destination" "logs" {
name = "${local.dash_domain}-email-identity-logs"
configuration_set_name = aws_sesv2_configuration_set.email.configuration_set_name
enabled = true
matching_types = [
"bounce",
"click",
"complaint",
"delivery",
"open",
"reject",
"renderingFailure",
"send"
]

cloudwatch_destination {
dimension_name = "email_type"
default_value = "other"
value_source = "messageTag"
}
}
39 changes: 39 additions & 0 deletions infra/modules/notifications-email-domain/resources/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This module manages an SESv2 email identity.
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
mail_from_domain = "mail.${var.domain_name}"
dash_domain = replace(var.domain_name, ".", "-")
}

# Verify email sender identity.
# Docs: https://docs.aws.amazon.com/pinpoint/latest/userguide/channels-email-manage-verify.html
resource "aws_sesv2_email_identity" "sender_domain" {
email_identity = local.dash_domain
configuration_set_name = aws_sesv2_configuration_set.email.configuration_set_name
}

# The configuration set applied to messages that is sent through this email channel.
resource "aws_sesv2_configuration_set" "email" {
configuration_set_name = var.domain_name

delivery_options {
tls_policy = "REQUIRE"
}

reputation_options {
reputation_metrics_enabled = true
}

sending_options {
sending_enabled = true
}
}

resource "aws_sesv2_email_identity_mail_from_attributes" "sender_domain" {
email_identity = aws_sesv2_email_identity.sender_domain.email_identity
mail_from_domain = local.mail_from_domain

depends_on = [aws_sesv2_email_identity.sender_domain]
}
3 changes: 3 additions & 0 deletions infra/modules/notifications-email-domain/resources/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "domain_identity_arn" {
value = aws_sesv2_email_identity.sender_domain.arn
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
variable "domain_name" {
description = "The domain name to configure SES, also used as the resource names"
type = string
}
5 changes: 5 additions & 0 deletions infra/modules/notifications/resources/email.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
resource "aws_pinpoint_email_channel" "app" {
application_id = aws_pinpoint_app.app.application_id
from_address = var.sender_display_name != null ? "${var.sender_display_name} <${var.sender_email}>" : var.sender_email
identity = var.domain_identity_arn
}
3 changes: 3 additions & 0 deletions infra/modules/notifications/resources/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
resource "aws_pinpoint_app" "app" {
name = var.name
}
3 changes: 3 additions & 0 deletions infra/modules/notifications/resources/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
output "app_id" {
value = aws_pinpoint_app.app.application_id
}
20 changes: 20 additions & 0 deletions infra/modules/notifications/resources/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
variable "name" {
type = string
description = "Name of the notifications project/application"
}

variable "sender_email" {
type = string
description = "Email address to use to send notification emails"
}

variable "sender_display_name" {
type = string
description = "The display name for notification emails. Only used if sender_email is provided"
default = null
}

variable "domain_identity_arn" {
type = string
description = "The ARN of the domain identity to use for sending emails"
}
2 changes: 2 additions & 0 deletions infra/project-config/aws_services.tf
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ locals {
"kms",
"lambda",
"logs",
"mobiletargeting", # this is pinpoint
"pipes",
"rds",
"route53",
Expand All @@ -27,6 +28,7 @@ locals {
"schemas",
"secretsmanager",
"servicediscovery",
"ses",
"sns",
"ssm",
"states",
Expand Down

0 comments on commit 1af2dbd

Please sign in to comment.