From 1af2dbd3c5ad44ad585adb0e5823f18276fe804d Mon Sep 17 00:00:00 2001 From: "kai [they]" Date: Tue, 3 Dec 2024 15:40:48 -0800 Subject: [PATCH] Adds email notifications (#786) ## Ticket Resolves https://github.com/navapbc/platform-test/pull/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: - https://github.com/navapbc/template-infra/issues/789 - https://github.com/navapbc/template-infra/issues/778 - https://github.com/navapbc/template-infra/issues/777 ## Testing See https://github.com/navapbc/platform-test/pull/141, specifically https://github.com/navapbc/platform-test/pull/141#issuecomment-2484476966 --- infra/app/app-config/dev.tf | 1 + .../app-config/env-config/notifications.tf | 16 ++++--- infra/app/app-config/main.tf | 5 ++- infra/app/app-config/outputs.tf | 4 ++ infra/app/app-config/prod.tf | 1 + infra/app/app-config/staging.tf | 1 + infra/app/service/main.tf | 1 + infra/app/service/notifications.tf | 44 +++++++++++++++++++ .../identity-provider/resources/main.tf | 13 ++---- .../identity-provider/resources/variables.tf | 6 +++ .../notifications-email-domain/data/main.tf | 3 ++ .../data/outputs.tf | 3 ++ .../data/variables.tf | 4 ++ .../resources/access-control.tf | 31 +++++++++++++ .../resources/dns.tf | 34 ++++++++++++++ .../resources/logs.tf | 23 ++++++++++ .../resources/main.tf | 39 ++++++++++++++++ .../resources/outputs.tf | 3 ++ .../resources/variables.tf | 4 ++ .../modules/notifications/resources/email.tf | 5 +++ infra/modules/notifications/resources/main.tf | 3 ++ .../notifications/resources/outputs.tf | 3 ++ .../notifications/resources/variables.tf | 20 +++++++++ infra/project-config/aws_services.tf | 2 + 24 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 infra/app/service/notifications.tf create mode 100644 infra/modules/notifications-email-domain/data/main.tf create mode 100644 infra/modules/notifications-email-domain/data/outputs.tf create mode 100644 infra/modules/notifications-email-domain/data/variables.tf create mode 100644 infra/modules/notifications-email-domain/resources/access-control.tf create mode 100644 infra/modules/notifications-email-domain/resources/dns.tf create mode 100644 infra/modules/notifications-email-domain/resources/logs.tf create mode 100644 infra/modules/notifications-email-domain/resources/main.tf create mode 100644 infra/modules/notifications-email-domain/resources/outputs.tf create mode 100644 infra/modules/notifications-email-domain/resources/variables.tf create mode 100644 infra/modules/notifications/resources/email.tf create mode 100644 infra/modules/notifications/resources/main.tf create mode 100644 infra/modules/notifications/resources/outputs.tf create mode 100644 infra/modules/notifications/resources/variables.tf diff --git a/infra/app/app-config/dev.tf b/infra/app/app-config/dev.tf index 995009aa7..4da30dcd9 100644 --- a/infra/app/app-config/dev.tf +++ b/infra/app/app-config/dev.tf @@ -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 diff --git a/infra/app/app-config/env-config/notifications.tf b/infra/app/app-config/env-config/notifications.tf index d4f2bb424..b351c73e1 100644 --- a/infra/app/app-config/env-config/notifications.tf +++ b/infra/app/app-config/env-config/notifications.tf @@ -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 } diff --git a/infra/app/app-config/main.tf b/infra/app/app-config/main.tf index 4928c9ecc..4791259a8 100644 --- a/infra/app/app-config/main.tf +++ b/infra/app/app-config/main.tf @@ -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 = { diff --git a/infra/app/app-config/outputs.tf b/infra/app/app-config/outputs.tf index 86407d31f..50318d9ac 100644 --- a/infra/app/app-config/outputs.tf +++ b/infra/app/app-config/outputs.tf @@ -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 } diff --git a/infra/app/app-config/prod.tf b/infra/app/app-config/prod.tf index fcc7d70d0..ec05268cb 100644 --- a/infra/app/app-config/prod.tf +++ b/infra/app/app-config/prod.tf @@ -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 diff --git a/infra/app/app-config/staging.tf b/infra/app/app-config/staging.tf index 342716d3a..613c079f0 100644 --- a/infra/app/app-config/staging.tf +++ b/infra/app/app-config/staging.tf @@ -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 diff --git a/infra/app/service/main.tf b/infra/app/service/main.tf index 50f9e137e..c3d220d47 100644 --- a/infra/app/service/main.tf +++ b/infra/app/service/main.tf @@ -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 ) diff --git a/infra/app/service/notifications.tf b/infra/app/service/notifications.tf new file mode 100644 index 000000000..423f5c3b8 --- /dev/null +++ b/infra/app/service/notifications.tf @@ -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 +} diff --git a/infra/modules/identity-provider/resources/main.tf b/infra/modules/identity-provider/resources/main.tf index 70096f4f9..7787b000b 100644 --- a/infra/modules/identity-provider/resources/main.tf +++ b/infra/modules/identity-provider/resources/main.tf @@ -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 @@ -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 { diff --git a/infra/modules/identity-provider/resources/variables.tf b/infra/modules/identity-provider/resources/variables.tf index f980c7054..a8b54f21c 100644 --- a/infra/modules/identity-provider/resources/variables.tf +++ b/infra/modules/identity-provider/resources/variables.tf @@ -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 diff --git a/infra/modules/notifications-email-domain/data/main.tf b/infra/modules/notifications-email-domain/data/main.tf new file mode 100644 index 000000000..0aac2ad05 --- /dev/null +++ b/infra/modules/notifications-email-domain/data/main.tf @@ -0,0 +1,3 @@ +data "aws_sesv2_email_identity" "main" { + email_identity = var.domain_name +} diff --git a/infra/modules/notifications-email-domain/data/outputs.tf b/infra/modules/notifications-email-domain/data/outputs.tf new file mode 100644 index 000000000..fcadb066a --- /dev/null +++ b/infra/modules/notifications-email-domain/data/outputs.tf @@ -0,0 +1,3 @@ +output "domain_identity_arn" { + value = data.aws_sesv2_email_identity.main.arn +} diff --git a/infra/modules/notifications-email-domain/data/variables.tf b/infra/modules/notifications-email-domain/data/variables.tf new file mode 100644 index 000000000..6ffda85d4 --- /dev/null +++ b/infra/modules/notifications-email-domain/data/variables.tf @@ -0,0 +1,4 @@ +variable "domain_name" { + type = string + description = "The domain name to use for the email identity" +} diff --git a/infra/modules/notifications-email-domain/resources/access-control.tf b/infra/modules/notifications-email-domain/resources/access-control.tf new file mode 100644 index 000000000..c8c452410 --- /dev/null +++ b/infra/modules/notifications-email-domain/resources/access-control.tf @@ -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/*" + } + } + } + ] + } + ) +} diff --git a/infra/modules/notifications-email-domain/resources/dns.tf b/infra/modules/notifications-email-domain/resources/dns.tf new file mode 100644 index 000000000..d877817ee --- /dev/null +++ b/infra/modules/notifications-email-domain/resources/dns.tf @@ -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"] +} diff --git a/infra/modules/notifications-email-domain/resources/logs.tf b/infra/modules/notifications-email-domain/resources/logs.tf new file mode 100644 index 000000000..2164c0f75 --- /dev/null +++ b/infra/modules/notifications-email-domain/resources/logs.tf @@ -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" + } +} diff --git a/infra/modules/notifications-email-domain/resources/main.tf b/infra/modules/notifications-email-domain/resources/main.tf new file mode 100644 index 000000000..318587c09 --- /dev/null +++ b/infra/modules/notifications-email-domain/resources/main.tf @@ -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] +} diff --git a/infra/modules/notifications-email-domain/resources/outputs.tf b/infra/modules/notifications-email-domain/resources/outputs.tf new file mode 100644 index 000000000..02f30706e --- /dev/null +++ b/infra/modules/notifications-email-domain/resources/outputs.tf @@ -0,0 +1,3 @@ +output "domain_identity_arn" { + value = aws_sesv2_email_identity.sender_domain.arn +} diff --git a/infra/modules/notifications-email-domain/resources/variables.tf b/infra/modules/notifications-email-domain/resources/variables.tf new file mode 100644 index 000000000..01af377be --- /dev/null +++ b/infra/modules/notifications-email-domain/resources/variables.tf @@ -0,0 +1,4 @@ +variable "domain_name" { + description = "The domain name to configure SES, also used as the resource names" + type = string +} diff --git a/infra/modules/notifications/resources/email.tf b/infra/modules/notifications/resources/email.tf new file mode 100644 index 000000000..b26043251 --- /dev/null +++ b/infra/modules/notifications/resources/email.tf @@ -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 +} diff --git a/infra/modules/notifications/resources/main.tf b/infra/modules/notifications/resources/main.tf new file mode 100644 index 000000000..36823eff1 --- /dev/null +++ b/infra/modules/notifications/resources/main.tf @@ -0,0 +1,3 @@ +resource "aws_pinpoint_app" "app" { + name = var.name +} diff --git a/infra/modules/notifications/resources/outputs.tf b/infra/modules/notifications/resources/outputs.tf new file mode 100644 index 000000000..0efd8378c --- /dev/null +++ b/infra/modules/notifications/resources/outputs.tf @@ -0,0 +1,3 @@ +output "app_id" { + value = aws_pinpoint_app.app.application_id +} diff --git a/infra/modules/notifications/resources/variables.tf b/infra/modules/notifications/resources/variables.tf new file mode 100644 index 000000000..723d80406 --- /dev/null +++ b/infra/modules/notifications/resources/variables.tf @@ -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" +} diff --git a/infra/project-config/aws_services.tf b/infra/project-config/aws_services.tf index a3c7cdeb2..0b0ffa26d 100644 --- a/infra/project-config/aws_services.tf +++ b/infra/project-config/aws_services.tf @@ -18,6 +18,7 @@ locals { "kms", "lambda", "logs", + "mobiletargeting", # this is pinpoint "pipes", "rds", "route53", @@ -27,6 +28,7 @@ locals { "schemas", "secretsmanager", "servicediscovery", + "ses", "sns", "ssm", "states",