diff --git a/README.md b/README.md index a50d4f0..084817f 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,39 @@ likely need to adjust the bucket policy statement with one like this: 8. Optionally set the S3 lifecycle for this bucket to delete/expire objects after a few days to clean up the saved emails. +## Set up with Terraform + +1. Clone the project and access the project directory +```bash +git clone https://github.com/arithmetric/aws-lambda-ses-forwarder.git +cd aws-lambda-ses-forwarder +``` + +2. Modify the values in the `config` object at the top of `index.js` to specify +the S3 bucket and object prefix for locating emails stored by SES. Also provide +the email forwarding mapping from original destinations to new destination. + +Use `YOURDOMAIN-aws-lambda-ses-forwarder-bucket` and replace YOURDOMAIN as +the name for your bucket. For example: `example.com-aws-lambda-ses-forwarder-bucket`. + +3. Zip index.js into a function.zip and move it to the terraform directory +```bash +zip function.zip index.js +mv function.zip ./terraform/ +``` + +4. Go to the terraform project and run terraform init and apply +```bash +cd terraform +terraform init +terraform apply +``` +Terraform apply will ask you for the domain name (with subdomains) and the base domain name. +If you are not using subdomains, you can use the same value. For example: `example.com`. + +If you are using an email key prefix, you should run +`terraform apply -var="email_key_prefix=YOURPREFIX"` instead. + ## Extending By loading aws-lambda-ses-forwarder as a module in a Lambda script, you can diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..bf9be80 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,5 @@ +.terraform +*.tfstate +*.tfstate.backup + +function.zip \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..ab1b44d --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "4.53.0" + constraints = "4.53.0" + hashes = [ + "h1:CymaUpULY6LR/rHl+4+Vs0i2jVHXMhSZuJj8VXqGIPs=", + "zh:0d44171544a916adf0fa96b7d0851a49d8dec98f71f0229dfd2d178958b3996b", + "zh:16945808ce26b86af7f5a77c4ab1154da786208c793abb95b8f918b4f48daded", + "zh:1a57a5a30cef9a5867579d894b74f60bb99afc7ca0d030d49a80ad776958b428", + "zh:2c718734ae17430d7f598ca0b4e4f86d43d66569c72076a10f4ace3ff8dfc605", + "zh:46fdf6301cb2fa0a4d122d1a8f75f047b6660c24851d6a4537ee38926a86485d", + "zh:53a53920b38a9e1648e85c6ee33bccf95bfcd067bffc4934a2af55621e6a6bd9", + "zh:548d927b234b1914c43169224b03f641d0961a4e312e5c6508657fce27b66db4", + "zh:57c847b2a5ae41ddea20b18ef006369d36bfdc4dec7f542f60e22a47f7b6f347", + "zh:79f7402b581621ba69f5a07ce70299735c678beb265d114d58955d04f0d39f87", + "zh:8970109a692dc4ecbda98a0969da472da4759db90ce22f2a196356ea85bb2cf7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a500cc4ffcad854dec0cf6f97751930a53c9f278f143a4355fa8892aa77c77bf", + "zh:b687c20b42a8b9e9e9f56c42e3b3c6859c043ec72b8907a6e4d4b64068e11df5", + "zh:e2c592e96822b78287554be43c66398f658c74c4ae3796f6b9e6d4b0f1f7f626", + "zh:ff1c4a46fdc988716c6fc28925549600093fc098828237cb1a30264e15cf730f", + ] +} diff --git a/terraform/lambda.tf b/terraform/lambda.tf new file mode 100644 index 0000000..147950f --- /dev/null +++ b/terraform/lambda.tf @@ -0,0 +1,56 @@ +// Setup the Lambda function +resource "aws_iam_role" "forwarder_function" { + name = "ses-forwarder-function-role" + + assume_role_policy = jsonencode( + { + "Version" : "2012-10-17", + "Statement" : [ + { + "Action" : "sts:AssumeRole", + "Principal" : { + "Service" : "lambda.amazonaws.com" + }, + "Effect" : "Allow", + "Sid" : "" + } + ] + } + ) +} +resource "aws_lambda_function" "forwarder" { + function_name = "ses-forwarder-function" + + filename = "function.zip" + source_code_hash = filebase64sha256("function.zip") + + role = aws_iam_role.forwarder_function.arn + runtime = "nodejs12.x" + handler = "index.handler" +} + +// Setup Lambda function logging +resource "aws_cloudwatch_log_group" "forwarder_function" { + name = "/aws/lambda/${aws_lambda_function.forwarder.function_name}" +} +resource "aws_iam_policy" "forwarder_function_logs" { + name = "ses-forwarder-function-logging-policy" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Action" : [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource" : "${aws_cloudwatch_log_group.forwarder_function.arn}:*", + "Effect" : "Allow" + } + ] + }) +} +resource "aws_iam_role_policy_attachment" "forwarder_function_logging" { + role = aws_iam_role.forwarder_function.name + policy_arn = aws_iam_policy.forwarder_function_logs.arn +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..d4d5cc1 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "4.53.0" + } + } + + # backend "s3" { + # bucket = "" + # region = "" + # dynamodb_table = "" + # encrypt = true + + # key = "" + # } +} + +provider "aws" { + default_tags { + tags = { + Project = "aws-lambda-ses-forwarder" + ManagedBy = "terraform" + } + } +} + +locals { + aws_ses_receipt_rule_name = "${var.domain}-aws-lambda-ses-forwarder" +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..d2b5e38 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,4 @@ +output "email_bucket_name" { + description = "Email bucket name" + value = aws_s3_bucket.email.id +} diff --git a/terraform/s3.tf b/terraform/s3.tf new file mode 100644 index 0000000..10f097e --- /dev/null +++ b/terraform/s3.tf @@ -0,0 +1,72 @@ +data "aws_caller_identity" "current" {} + +resource "aws_s3_bucket" "email" { + bucket = "${var.domain}-aws-lambda-ses-forwarder-bucket" + + # Might have to uncomment this to be able to destroy the bucket + # force_destroy = true +} + +resource "aws_s3_bucket_acl" "email" { + bucket = aws_s3_bucket.email.id + acl = "private" +} + +resource "aws_s3_bucket_public_access_block" "email" { + bucket = aws_s3_bucket.email.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +// Allow SES to put emails +resource "aws_s3_bucket_policy" "email" { + bucket = aws_s3_bucket.email.id + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Sid" : "AllowSESPuts", + "Effect" : "Allow", + "Principal" : { + "Service" : "ses.amazonaws.com" + }, + "Action" : "s3:PutObject", + "Resource" : "${aws_s3_bucket.email.arn}/*", + "Condition" : { + "StringEquals" : { + "AWS:SourceAccount" : "${data.aws_caller_identity.current.account_id}", + "AWS:SourceArn" : "arn:aws:ses:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:receipt-rule-set/${data.aws_ses_active_receipt_rule_set.main.rule_set_name}:receipt-rule/${local.aws_ses_receipt_rule_name}" + #"AWS:SourceArn" : "${aws_ses_receipt_rule.store.arn}" # Can't use because of dependencies + } + } + } + ] + }) +} + +// Allow Lambda to put and get emails +resource "aws_iam_policy" "forwarder_function_bucket" { + name = "ses-forwarder-function-bucket-policy" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "s3:GetObject", + "s3:PutObject" + ], + "Resource" : "${aws_s3_bucket.email.arn}/*" + } + ] + }) +} +resource "aws_iam_role_policy_attachment" "forwarder_function_bucket" { + role = aws_iam_role.forwarder_function.name + policy_arn = aws_iam_policy.forwarder_function_bucket.arn +} diff --git a/terraform/ses.tf b/terraform/ses.tf new file mode 100644 index 0000000..ab23c7e --- /dev/null +++ b/terraform/ses.tf @@ -0,0 +1,117 @@ +data "aws_region" "current" {} + +data "aws_route53_zone" "domain" { + name = var.base_domain +} + +resource "aws_ses_domain_identity" "domain" { + domain = var.domain +} + +resource "aws_ses_domain_dkim" "domain" { + domain = aws_ses_domain_identity.domain.domain +} + +resource "aws_ses_domain_mail_from" "domain" { + domain = aws_ses_domain_identity.domain.domain + mail_from_domain = "bounce.${aws_ses_domain_identity.domain.domain}" +} + +resource "aws_route53_record" "domain_dkim" { + count = 3 + zone_id = data.aws_route53_zone.domain.id + name = "${aws_ses_domain_dkim.domain.dkim_tokens[count.index]}._domainkey.${aws_ses_domain_identity.domain.domain}" + type = "CNAME" + ttl = "600" + records = ["${aws_ses_domain_dkim.domain.dkim_tokens[count.index]}.dkim.amazonses.com"] + + allow_overwrite = var.allow_route53_overwrite +} + +resource "aws_route53_record" "domain_from_mx" { + zone_id = data.aws_route53_zone.domain.id + name = aws_ses_domain_mail_from.domain.mail_from_domain + type = "MX" + ttl = "600" + records = ["10 feedback-smtp.${data.aws_region.current.name}.amazonses.com"] + + allow_overwrite = var.allow_route53_overwrite +} + +resource "aws_route53_record" "domain_from_txt" { + zone_id = data.aws_route53_zone.domain.id + name = aws_ses_domain_mail_from.domain.mail_from_domain + type = "TXT" + ttl = "600" + records = ["v=spf1 include:amazonses.com -all"] + + allow_overwrite = var.allow_route53_overwrite +} + +# Email receiving https://docs.aws.amazon.com/ses/latest/dg/receiving-email-mx-record.html +resource "aws_route53_record" "domain_receiving" { + zone_id = data.aws_route53_zone.domain.id + name = aws_ses_domain_identity.domain.domain + type = "MX" + ttl = "600" + records = ["10 inbound-smtp.${data.aws_region.current.name}.amazonaws.com"] + + allow_overwrite = var.allow_route53_overwrite +} + +// Allow Lambda to send emails +resource "aws_iam_policy" "forwarder_function_send_email" { + name = "ses-forwarder-function-send-email-policy" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : "ses:SendRawEmail", + "Resource" : "${aws_ses_domain_identity.domain.arn}" + } + ] + }) +} +resource "aws_iam_role_policy_attachment" "forwarder_function_send_email" { + role = aws_iam_role.forwarder_function.name + policy_arn = aws_iam_policy.forwarder_function_send_email.arn +} + + +# Allow SES to invoke our Lambda forwarder function +resource "aws_lambda_permission" "ses_invoke" { + statement_id = "AllowSESInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.forwarder.function_name + principal = "ses.amazonaws.com" + source_arn = "arn:aws:ses:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:receipt-rule-set/${data.aws_ses_active_receipt_rule_set.main.rule_set_name}:receipt-rule/${local.aws_ses_receipt_rule_name}" +} + +data "aws_ses_active_receipt_rule_set" "main" {} + +resource "aws_ses_receipt_rule" "store" { + depends_on = [ + aws_s3_bucket_policy.email, + aws_lambda_permission.ses_invoke + ] + + name = local.aws_ses_receipt_rule_name + rule_set_name = data.aws_ses_active_receipt_rule_set.main.rule_set_name + + enabled = true + recipients = [var.domain] + scan_enabled = true + + s3_action { + position = 1 + bucket_name = aws_s3_bucket.email.id + object_key_prefix = var.email_key_prefix + } + + lambda_action { + position = 2 + function_arn = aws_lambda_function.forwarder.arn + } +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..f619fd9 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,27 @@ +variable "domain" { + description = "Domain name" + type = string +} + +# This should be the name of the hosted zone, without subdomains +variable "base_domain" { + description = "Domain base name" + type = string +} + +# TODO: +# If the SES identity is already verified through Route53 records, Terraform might try +# to overwrite those records with the same ones and if this is not set to true, it +# will cancel the deployment. +# Terraform should check if the records are already created instead. +variable "allow_route53_overwrite" { + description = "Allow overwriting Route53 SES records" + type = bool + default = false +} + +variable "email_key_prefix" { + description = "Email key prefix" + type = string + default = "" +}