Catalogue
Fixing SES Emails Landing in Spam with a Dedicated Sending Subdomain and DKIM

Fixing SES Emails Landing in Spam with a Dedicated Sending Subdomain and DKIM

🌐 日本語で読む

Login notification emails (magic links) sent from an internal system via Lambda + SES started landing in spam, so I fixed it with a dedicated sending subdomain: an SES domain identity with Easy DKIM, a custom MAIL FROM domain, and DMARC.
This post covers the NS delegation design under the constraint that the parent zone lives in a different AWS account and repository, and how I staged the migration in Terraform.

The Problem

The original SES setup was:

  • The sender was an email-address-level aws_ses_email_identity (a personal address)
  • No DKIM signature
  • SPF misalignment (MAIL FROM stayed at amazonses.com and didn’t align with the From domain)

With such weak signals, receivers routed the mail to the spam folder.

Aside: SES IAM authorization also applies to the destination identity

Before the spam issue, SES sending itself had failed with AccessDenied. I had restricted the ses:SendEmail IAM resource to the sender identity only, but when the destination is a verified identity in the same account, AWS also performs an authorization check against the destination identity (confirmed via the actual log: ... is not authorized to perform 'ses:SendEmail' on resource 'identity/<recipient email>').

1
2
3
4
5
6
7
8
9
10
11
12
13
# SES checks authorization not only against the From identity, but also against
# the destination identity when it is a verified identity in the account
statement {
effect = "Allow"
actions = [
"ses:SendEmail",
"ses:SendRawEmail",
]
resources = concat(
[aws_ses_domain_identity.mail_from.arn],
[for identity in aws_ses_email_identity.recipient : identity.arn]
)
}

The Fix

Switch to a domain identity on a dedicated sending subdomain (e.g. myapp.example.co.jp) with the full authentication trio:

MeasureDetails
DKIMEasy DKIM (3 CNAMEs)
SPF alignmentCustom MAIL FROM domain (bounce. subdomain + MX + SPF)
DMARCStart with p=none, raise gradually after confirming DKIM/SPF pass

The constraint: the parent zone (example.co.jp) is managed in a different AWS account and a different repository. The subdomain’s hosted zone had to be created in the app repository and NS-delegated from the parent.

NS Delegation Design: Delegate stg Inside the prd Zone

There are two environments, stg and prd. The naive approach adds two NS records to the parent zone, but I ended up with this chain:

1
2
3
example.co.jp (separate account, company-wide DNS repository)
└─ NS delegation → myapp.example.co.jp (app prd account)
└─ NS delegation → stg.myapp.example.co.jp (app stg account)

The dependency on the company-wide DNS repository is minimized to a single prd record, and the stg delegation is completed inside prd’s hosted zone. Rebuilding the stg environment never touches the company-wide repository — the lifecycle stays within the app’s own repositories.

NS record values are not hard-coded; they reference the app-side outputs via terraform_remote_state.

1
2
3
4
5
6
7
8
9
10
# App prd: delegate the stg subdomain inside this zone
# NOTE: the parent zone only delegates the prd domain; delegating stg within this zone
# keeps stg environment rebuilds from affecting the parent zone
resource "aws_route53_record" "mail_stg_delegation" {
zone_id = aws_route53_zone.mail.zone_id
name = "stg.${local.mail_domain}"
type = "NS"
ttl = 300
records = data.terraform_remote_state.stg.outputs.mail_zone_name_servers
}

Terraform Implementation and Staged Migration

Domain verification cannot pass until the parent-zone NS delegation is done, so I split “resource creation” and “verification + cutover” into two PRs. Doing verification in the same apply risks a verification timeout and an email outage while delegation is incomplete.

Step 1: create SES + DNS resources first (without switching the From address)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
resource "aws_ses_domain_identity" "mail_from" {
domain = local.mail_domain # e.g. myapp.example.co.jp
}

# Easy DKIM signing
resource "aws_ses_domain_dkim" "mail_from" {
domain = aws_ses_domain_identity.mail_from.domain
}

# Custom MAIL FROM domain for SPF alignment
resource "aws_ses_domain_mail_from" "mail_from" {
domain = aws_ses_domain_identity.mail_from.domain
mail_from_domain = "bounce.${local.mail_domain}"
}

The DNS side is the hosted zone + verification TXT + 3 DKIM CNAMEs + MAIL FROM MX/SPF + DMARC.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# NOTE: 3 CNAME records for Easy DKIM. dkim_tokens are unknown until after apply on
# first creation, so they cannot be for_each + toset keys — use count on the
# assumption there are always 3
resource "aws_route53_record" "mail_dkim" {
count = 3

zone_id = aws_route53_zone.mail.zone_id
name = "${aws_ses_domain_dkim.mail_from.dkim_tokens[count.index]}._domainkey.${local.mail_domain}"
type = "CNAME"
ttl = 600
records = ["${aws_ses_domain_dkim.mail_from.dkim_tokens[count.index]}.dkim.amazonses.com"]
}

resource "aws_route53_record" "mail_from_mx" {
zone_id = aws_route53_zone.mail.zone_id
name = aws_ses_domain_mail_from.mail_from.mail_from_domain
type = "MX"
ttl = 600
records = ["10 feedback-smtp.${data.aws_region.current.region}.amazonses.com"]
}

resource "aws_route53_record" "mail_from_spf" {
zone_id = aws_route53_zone.mail.zone_id
name = aws_ses_domain_mail_from.mail_from.mail_from_domain
type = "TXT"
ttl = 600
records = ["v=spf1 include:amazonses.com ~all"]
}

# NOTE: start with p=none; raise to p=quarantine in a later PR after confirming DKIM/SPF
resource "aws_route53_record" "mail_dmarc" {
zone_id = aws_route53_zone.mail.zone_id
name = "_dmarc.${local.mail_domain}"
type = "TXT"
ttl = 600
records = ["v=DMARC1; p=none;"]
}

Expose the name servers as an output for the delegation:

1
2
3
4
output "mail_zone_name_servers" {
description = "NS records of the dedicated sending subdomain, used for parent-zone delegation"
value = aws_route53_zone.mail.name_servers
}

Step 2: verification + cutover after NS delegation completes

Only after the delegation is in place do I add aws_ses_domain_identity_verification (adding it earlier makes the apply time out).

1
2
3
4
5
6
7
8
9
10
11
# NOTE: waits for domain ownership verification. The TXT record must be resolvable
# through the delegated zone, hence the explicit depends_on
resource "aws_ses_domain_identity_verification" "mail_from" {
domain = aws_ses_domain_identity.mail_from.domain

depends_on = [aws_route53_record.mail_verification]

timeouts {
create = "10m"
}
}

Once verification passed, I switched the From address to noreply@myapp.example.co.jp and deleted the old aws_ses_email_identity. Keep the old identity alive until verification completes — that is the safe order.

Small traps

  • dkim_tokens are unknown until after apply, so they can’t be for_each + toset keys — write count = 3 on the always-three assumption
  • Managing the same email address in two Terraform resources (sender and recipient) means destroying one collaterally revokes the other identity
  • I later wanted to shorten the subdomain (mail.prd.myapp... → myapp...), which meant recreating the hosted zone and redoing the NS delegation. Decide upfront whether the subdomain will ever serve anything besides email

Result

The sender is now a domain identity with DKIM signing, SPF alignment, and DMARC, and the spam classification is gone. Raising DMARC from p=none to p=quarantine will follow separately while watching the reports.

Summary

  • An email-address SES identity (no DKIM, misaligned SPF) is prone to spam classification; a dedicated sending subdomain with Easy DKIM + custom MAIL FROM + DMARC fixes it
  • When the parent zone is managed elsewhere, split “resource creation” and “verification + cutover” into two PRs
  • Delegating stg inside the prd zone minimizes dependence on company-wide DNS
  • SES IAM authorization also applies to verified destination identities

I hope this helps.

References

kenzo0107

kenzo0107