diff --git a/cloudwatch/metric/main.tf b/cloudwatch/metric/main.tf index ae7c370a..5ff2b6a2 100644 --- a/cloudwatch/metric/main.tf +++ b/cloudwatch/metric/main.tf @@ -9,5 +9,5 @@ locals { var.color, ])) - id = "m_${var.name}_${local.vars_hash}" + id = "m_${replace(var.name, ".", "_")}_${local.vars_hash}" } diff --git a/cloudwatch/metric/many/main.tf b/cloudwatch/metric/many/main.tf index 397306bb..7cf711b5 100644 --- a/cloudwatch/metric/many/main.tf +++ b/cloudwatch/metric/many/main.tf @@ -7,7 +7,7 @@ module "default" { locals { out = [for v in var.vars : { - id = "m_${v.name}_${md5(jsonencode([ + id = "m_${replace(v.name, ".", "_")}_${md5(jsonencode([ v.namespace, v.name, try(v.dimensions, module.default.dimensions), @@ -27,7 +27,7 @@ locals { }] out_map = { for k, v in var.vars_map : k => { - id = "m_${v.name}_${md5(jsonencode([ + id = "m_${replace(v.name, ".", "_")}_${md5(jsonencode([ v.namespace, v.name, try(v.dimensions, module.default.dimensions), diff --git a/ses/domain/README.md b/ses/domain/README.md index 8cefd7cd..0ff473f5 100644 --- a/ses/domain/README.md +++ b/ses/domain/README.md @@ -100,6 +100,18 @@ Registers a domain with AWS SES and verifies it ## Outputs +* `configuration_set` + + Configuration set to use to track metrics for this domain + +* `email_headers` + + Headers that should be included in each email + +* `metrics` + + Cloudwatch metrics, see [metrics.tf](./metrics.tf) for details + * `sender_policy_arn` IAM policy ARN for email senders @@ -111,3 +123,7 @@ Registers a domain with AWS SES and verifies it * `spf_record` SPF record which you should include in the domain's TXT record in case you specified `spf = false` + +* `widgets` + + Cloudwatch dashboard widgets, see [widgets.tf](./widgets.tf) for details diff --git a/ses/domain/example/bin/spammer b/ses/domain/example/bin/spammer new file mode 100755 index 00000000..780a4317 --- /dev/null +++ b/ses/domain/example/bin/spammer @@ -0,0 +1,25 @@ +#!/bin/sh + +set -e + +image_id=$( +docker build -q -f - bin < + + +

{email_type} email {i}

+

codequest-eu/terraform-modules

+ + + """ + ), + subtype="html", + ) + + if email_type == "reject": + email_message.add_attachment( + EICAR_TEST_FILE, + filename="sample.txt", + maintype="application", + subtype="octet-stream", + ) + + return email_message + +def main(): + args = parser.parse_args() + + if args.reject and not args.reject_email: + print("You have to specify --reject-email or disable reject emails with --reject 0") + sys.exit(1) + + ses = boto3.client("ses") + + print(f"Will start sending emails from {args.sender} every {args.interval} seconds") + + for i in count(start=1): + time.sleep(args.interval) + + email_message = build_email_message(i, args) + subject = email_message["subject"] + recipient = email_message["to"] + + print(f"Sending {subject} to {recipient}...") + message_id = ses.send_raw_email( + RawMessage=dict(Data=email_message.as_bytes()), + )["MessageId"] + print(f"Sent {message_id}") + + +if __name__ == "__main__": + main() diff --git a/ses/domain/example/main.tf b/ses/domain/example/main.tf index 27b0a41f..6661b260 100644 --- a/ses/domain/example/main.tf +++ b/ses/domain/example/main.tf @@ -19,6 +19,28 @@ module "domain" { hosted_zone_id = data.aws_route53_zone.zone.id } +output "domain" { + value = "ses-domain.terraform-modules-examples.${var.zone_domain}" +} + +output "configuration_set" { + value = module.domain.configuration_set +} + output "sender_policy_arn" { value = module.domain.sender_policy_arn } + +module "dashboard" { + source = "./../../../cloudwatch/dashboard" + + name = "terraform-modules-ses-domain-example" + widgets = [ + module.domain.widgets.delivery, + module.domain.widgets.delivery_percentage, + module.domain.widgets.spam, + module.domain.widgets.conversion, + module.domain.widgets.account_bounce_rate, + module.domain.widgets.account_spam_rate, + ] +} diff --git a/ses/domain/main.tf b/ses/domain/main.tf index 449b7172..5009d98a 100644 --- a/ses/domain/main.tf +++ b/ses/domain/main.tf @@ -121,7 +121,7 @@ resource "aws_route53_record" "dmarc" { records = [join(";", [for k, v in local.dmarc_options : "${k}=${v}"])] } -# sender policy +# sender policy --------------------------------------------------------------- data "aws_iam_policy_document" "sender" { count = var.create ? 1 : 0 @@ -150,3 +150,41 @@ resource "aws_iam_policy" "sender" { description = "Allows sending emails from @${local.domain}" policy = data.aws_iam_policy_document.sender[0].json } + +# configuration set for cloudwatch metrics ------------------------------------ + +resource "aws_ses_configuration_set" "domain" { + count = var.create ? 1 : 0 + + name = replace(local.domain, ".", "-") +} + +locals { + configuration_set_name = var.create ? aws_ses_configuration_set.domain[0].name : "" +} + +resource "aws_ses_event_destination" "metrics" { + count = var.create ? 1 : 0 + + configuration_set_name = local.configuration_set_name + name = "metrics" + enabled = true + + # track all types + matching_types = [ + "send", + "reject", + "bounce", + "complaint", + "delivery", + "open", + "click", + "renderingFailure" + ] + + cloudwatch_destination { + value_source = "messageTag" + dimension_name = "ses:from-domain" + default_value = local.domain + } +} diff --git a/ses/domain/metrics.tf b/ses/domain/metrics.tf new file mode 100644 index 00000000..4898ab39 --- /dev/null +++ b/ses/domain/metrics.tf @@ -0,0 +1,85 @@ +locals { + metrics = { + emails = module.metrics_count.out_map.sent + rejections = module.metrics_count.out_map.rejections + bounces = module.metrics_count.out_map.bounces + spam = module.metrics_count.out_map.spam + deliveries = module.metrics_count.out_map.deliveries + opens = module.metrics_count.out_map.opens + clicks = module.metrics_count.out_map.clicks + + rejections_percentage = module.metrics_percentage.out_map.rejections + bounces_percentage = module.metrics_percentage.out_map.bounces + deliveries_percentage = module.metrics_percentage.out_map.deliveries + + account_bounce_rate = module.metrics_account_reputation.out_map.bounce + account_spam_rate = module.metrics_account_reputation.out_map.spam + } +} + +module "cloudwatch_consts" { + source = "./../../cloudwatch/consts" +} + +locals { + colors = module.cloudwatch_consts.colors +} + +locals { + domain_dimensions = { + "ses:from-domain" = local.domain + } + + metrics_count = { + sent = { name = "Send", label = "Sent", color = local.colors.grey } + rejections = { name = "Reject", label = "Rejections", color = local.colors.red } + bounces = { name = "Bounce", label = "Bounces", color = local.colors.orange } + spam = { name = "Complaint", label = "Marked as spam", color = local.colors.red } + deliveries = { name = "Delivery", label = "Deliveries", color = local.colors.green } + opens = { name = "Open", label = "Opens", color = local.colors.light_green } + clicks = { name = "Click", label = "Link clicks", color = local.colors.green } + } +} + +module "metrics_count" { + source = "./../../cloudwatch/metric/many" + + vars_map = { for k, v in local.metrics_count : k => { + namespace = "AWS/SES" + dimensions = local.domain_dimensions + name = v.name + label = v.label + color = v.color + stat = "SampleCount" + period = 300 + } } +} + +module "metrics_percentage" { + source = "./../../cloudwatch/metric_expression/many" + + vars_map = { for k, v in local.metrics_count : k => { + expression = "100 * ${module.metrics_count.out_map[k].id} / FILL(${module.metrics_count.out_map.sent.id}, 1)" + label = v.label + color = v.color + } } +} + +locals { + metrics_reputation = { + bounce = { name = "Bounce", label = "Bounce" } + spam = { name = "Complaint", label = "Spam" } + } +} + +module "metrics_account_reputation" { + source = "./../../cloudwatch/metric/many" + + vars_map = { for k, v in local.metrics_reputation : k => { + namespace = "AWS/SES" + name = "Reputation.${v.name}Rate" + label = "${v.label} rate" + stat = "Average" + period = 3600 + } } +} diff --git a/ses/domain/outputs.tf b/ses/domain/outputs.tf index 71670052..bf4571fc 100644 --- a/ses/domain/outputs.tf +++ b/ses/domain/outputs.tf @@ -12,3 +12,25 @@ output "sender_policy_arn" { description = "IAM policy ARN for email senders" value = var.create ? aws_iam_policy.sender[0].arn : null } + +output "configuration_set" { + description = "Configuration set to use to track metrics for this domain" + value = local.configuration_set_name +} + +output "email_headers" { + description = "Headers that should be included in each email" + value = { + "X-SES-CONFIGURATION-SET" = local.configuration_set_name + } +} + +output "metrics" { + value = local.metrics + description = "Cloudwatch metrics, see [metrics.tf](./metrics.tf) for details" +} + +output "widgets" { + value = local.widgets + description = "Cloudwatch dashboard widgets, see [widgets.tf](./widgets.tf) for details" +} diff --git a/ses/domain/widgets.tf b/ses/domain/widgets.tf new file mode 100644 index 00000000..5f0db399 --- /dev/null +++ b/ses/domain/widgets.tf @@ -0,0 +1,123 @@ +locals { + widgets = { + delivery = module.widget_delivery, + delivery_percentage = module.widget_delivery_percentage + spam = module.widget_spam + conversion = module.widget_conversion, + account_bounce_rate = module.widget_account_bounce_rate + account_spam_rate = module.widget_account_spam_rate + } +} + +module "widget_delivery" { + source = "./../../cloudwatch/metric_widget" + + title = "Email delivery" + stacked = true + left_metrics = [ + local.metrics.rejections, + local.metrics.bounces, + local.metrics.deliveries, + ] + left_range = [0, null] +} + +module "widget_delivery_percentage" { + source = "./../../cloudwatch/metric_widget" + + title = "Email delivery percentages" + stacked = true + left_metrics = [ + local.metrics.rejections_percentage, + local.metrics.bounces_percentage, + local.metrics.deliveries_percentage, + ] + left_range = [0, null] + hidden_metrics = [ + local.metrics.emails, + local.metrics.rejections, + local.metrics.bounces, + local.metrics.deliveries, + ] +} + +module "widget_spam" { + source = "./../../cloudwatch/metric_widget" + + title = "Email spam complaints" + left_metrics = [local.metrics.spam] + left_range = [0, null] +} + +module "widget_conversion" { + source = "./../../cloudwatch/metric_widget" + + title = "Email conversion" + left_metrics = [ + local.metrics.emails, + merge(local.metrics.deliveries, { color = local.colors.light_olive }), + local.metrics.opens, + local.metrics.clicks, + ] + left_range = [0, null] +} + +module "annotation_warning_bounce_rate" { + source = "./../../cloudwatch/annotation" + + value = 0.05 + color = local.colors.orange + fill = "above" + label = "High rate" +} + +module "annotation_max_bounce_rate" { + source = "./../../cloudwatch/annotation" + + value = 0.1 + color = local.colors.red + fill = "above" + label = "Ban rate" +} + +module "widget_account_bounce_rate" { + source = "./../../cloudwatch/metric_widget" + + title = "SES account bounce rate" + left_metrics = [local.metrics.account_bounce_rate] + left_range = [0, null] + left_annotations = [ + module.annotation_warning_bounce_rate, + module.annotation_max_bounce_rate, + ] +} + +module "annotation_warning_spam_rate" { + source = "./../../cloudwatch/annotation" + + value = 0.001 + color = local.colors.orange + fill = "above" + label = "High rate" +} + +module "annotation_max_spam_rate" { + source = "./../../cloudwatch/annotation" + + value = 0.005 + color = local.colors.red + fill = "above" + label = "Ban rate" +} + +module "widget_account_spam_rate" { + source = "./../../cloudwatch/metric_widget" + + title = "SES account spam rate" + left_metrics = [local.metrics.account_spam_rate] + left_range = [0, null] + left_annotations = [ + module.annotation_warning_spam_rate, + module.annotation_max_spam_rate, + ] +}