Provisioning SES In Your CDK Stack

Prerequisites

Before we can jump into the tutorial, we first need to tick off some prerequisites, notably, we need to have an AWS account created with the AWS CDK configured and set up on our local machine. We then need to have a CDK project that we want to add our SES configuration to, this can be either an existing one or a new one which you can create using cdk init app --language typescript .

Once you have these things sorted, we’re ready to start diving into DKIM, SPF and how to configure them on SES via the CDK.

Tracking Clicks and Opens

const { name, removalPolicy, tracking } = props;

if (tracking) {
  const { domain, zone, cert } = tracking;

  this.clickDomain = this.createDomainIdentity({
    zone,
    domain,
    domainFromPrefix: "email",
  });

  this.clickDistribution = new Distribution(
    this,
    `${name}-ses-click-distribution`,
    {
      defaultBehavior: {
        origin: new HttpOrigin(`r.${Stack.of(this).region}.awstrack.me`),
        cachePolicy: new CachePolicy(this, `${name}-ses-cache`, {
          cachePolicyName: `${name}-ses-cache-policy`,
          comment: "Policy to cache host header",
          headerBehavior: {
            behavior: "whitelist",
            headers: ["Host"],
          },
        }),
      },
      domainNames: [this.clickDomain.emailIdentityName],
      certificate: cert,
    },
  );

  const _trackingRecord = new ARecord(this, `${name}-ses-click-record`, {
    recordName: domain,
    zone,
    target: RecordTarget.fromAlias(
      new CloudFrontTarget(this.clickDistribution),
    ),
  });
}

How DKIM on SES works

Implementing DKIM with SES

const configSetName = `${name}-config-set`;

this.configSet = new ses.ConfigurationSet(this, configSetName, {
  tlsPolicy: ses.ConfigurationSetTlsPolicy.REQUIRE,
  suppressionReasons: ses.SuppressionReasons.BOUNCES_AND_COMPLAINTS,
  configurationSetName: configSetName,
  customTrackingRedirectDomain: tracking?.domain,
});

if (this.clickDistribution) {
  this.configSet.node.addDependency(this.clickDistribution);
}
this.domainIdentities = props.domains.map((identity) =>
  this.createDomainIdentity(identity, this.configSet),
);
export class SES extends Construct {
  public readonly configSet: ses.ConfigurationSet;
  public readonly eventQueue: Queue;
  public readonly identities: ses.IEmailIdentity[];
  public readonly clickDomain?: ses.IEmailIdentity;
  public readonly clickDistribution?: Distribution;

  public constructor(scope: Construct, id: string, props: Readonly<SESProps>) {
    super(scope, id);
    // ...
  }

  private createDomainIdentity(
    { zone, domain, domainFromPrefix, ruaEmail, rufEmail }: DomainIdentity,
    configurationSet?: IConfigurationSet,
  ): ses.IEmailIdentity {
    const slugify = (str: string): string =>
      String(str)
        .normalize("NFKD")
        .replace(/[\u0300-\u036f]/g, "")
        .trim()
        .toLowerCase()
        .replace(/[^a-z0-9 -]/g, "")
        .replace(/\s+/g, "-")
        .replace(/-+/g, "-");

    const { domain: rootDomain, subdomain } = parse(domain);

    if (rootDomain === null || subdomain === null) {
      throw new Error(`Invalid domain: ${domain}`);
    }

    const domainSlug = slugify(domain);
    const domainIdentityName = `${domainSlug}-identity`;
    const identity = new ses.EmailIdentity(this, domainIdentityName, {
      identity: ses.Identity.domain(domain),
      mailFromDomain: `${domainFromPrefix}.${domain}`,
      configurationSet,
      mailFromBehaviorOnMxFailure:
        ses.MailFromBehaviorOnMxFailure.REJECT_MESSAGE,
    });

    const dkimTokens = [
      [identity.dkimDnsTokenName1, identity.dkimDnsTokenValue1],
      [identity.dkimDnsTokenName2, identity.dkimDnsTokenValue2],
      [identity.dkimDnsTokenName3, identity.dkimDnsTokenValue3],
    ];

    dkimTokens.forEach(([tokenName, tokenValue], i) => {
      const recordName = `${tokenName}.`;
      const domainName = tokenValue;
      const _record = new CnameRecord(
        this,
        `${domainSlug}-dkim-token-${i + 1}`,
        {
          recordName,
          domainName,
          comment: `SES DKIM Record ${i + 1} for ${domain}`,
          zone,
        },
      );

      new CfnOutput(this, `${domainSlug}-dkim-token-value-${i + 1}`, {
        description: `SES DKIM CNAME Record ${i + 1}`,
        value: `${recordName} CNAME ${tokenValue}.dkim.amazonses.com`,
      });
    });

    let dmarcValue = "v=DMARC1; p=none; ";
    if (ruaEmail) {
      dmarcValue += `rua=mailto:${ruaEmail}; `;
    }

    if (rufEmail) {
      dmarcValue += `ruf=mailto:${rufEmail}; `;
    }

    dmarcValue = dmarcValue.trim();

    const _dmarcRecord = new TxtRecord(this, `${domainSlug}-dmarc-recordset`, {
      recordName: `_dmarc.${subdomain}`,
      values: [dmarcValue],
      zone,
    });

    const spfValue = "v=spf1 include:amazonses.com ~all";
    const _txtRecord = new TxtRecord(this, `${domainSlug}-txt-recordset`, {
      recordName: `${domainFromPrefix}.${subdomain}`,
      values: [spfValue],
      zone,
    });

    const _mxRecord = new MxRecord(this, `${domainSlug}-mx-recordset`, {
      recordName: `${domainFromPrefix}.${subdomain}`,
      values: [
        {
          priority: 10,
          hostName: `feedback-smtp.${Stack.of(this).region}.amazonses.com`,
        },
      ],
      zone,
    });

    return identity;
  }
}

Tracking Other Events

const eventTopicName = `${name}-email-topic`;
const eventTopic = new Topic(this, eventTopicName, {
  topicName: eventTopicName,
  displayName: "SES Email Notifications",
});

const eventDestinationName = `${name}-notification-destination`;
const _eventDestination = new ses.ConfigurationSetEventDestination(
  this,
  eventDestinationName,
  {
    events: allowedEventTypes,
    destination: ses.EventDestination.snsTopic(eventTopic),
    configurationSet: this.configSet,
    configurationSetEventDestinationName: eventDestinationName,
  },
);

const queueName = `${name}-email-notifications`;
const notificationQueue = new Queue(this, queueName, {
  queueName,
  enforceSSL: true,
  removalPolicy,
});

eventTopic.addSubscription(new SqsSubscription(notificationQueue));

this.eventQueue = notificationQueue;

Testing our Configuration

At this point, we’re almost ready to test our SES configuration but first, we need to make sure the email address we added in the CDK stack is verified and ready to be used.

To complete the verification, you should have received an email to the email address you configured in the CDK stack asking you to verify your email address for use with SES, inside this email there should be a link to complete the process. Once you’ve clicked on that link your email should be verified with SES and you should be ready to send emails using it.

Closing Thoughts

In this tutorial, we’ve looked at how to protect our domain from potentially malicious and unauthorised users by configuring DKIM and SPF DNS records in AWS SES using the AWS CDK.

Now, at this point, you may be asking what happened to the third authentication method we mentioned at the start, DMARC. We didn’t cover it in this tutorial, as it’s not configured specifically for SES but rather, it is a general DNS record that applies to the domain as a whole. Saying this however you should configure it for your domain alongside DKIM and SPF. The AWS documentation covers DMARC and how to configure it in detail here.

Finally, if you’d like to see the full example code/project for this tutorial , you can see it in my CDK Tutorials GitHub repository along with all of my other CDK tutorials and projects.

I hope you found this post helpful and thank you for reading.

Marcin Praski
Written by

Software engineer. I enjoy working with distributed systems, databases and broadly defined cloud-based solutions.