Provisioning SES in Your CDK Stack

Table of Contents

This article is still work-in-progress.

Building blocks of SES

Bootstrapping the construct

Tracking clicks and opens

Verifying email domains with Route53

Receiving notifications via SNS and SQS

import { RemovalPolicy } from "aws-cdk-lib";

export interface SESProps {
	domains: DomainIdentity[];
	tracking?: TrackingIdentity;
	removalPolicy: RemovalPolicy;
}

export interface DomainIdentity {
	zone: IHostedZone;
	domain: string;
	domainFromPrefix: string;
	ruaEmail?: string;
}

export interface TrackingIdentity {
	zone: IHostedZone;
	cert: ICertificate;
	domain: string;
}
import { RemovalPolicy } from "aws-cdk-lib";

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);

		const { name, removalPolicy, tracking } = props;

        // Create a domain for tracking clicks & opens
		if (tracking) {
			this.createClickDistribution();
		}

		// Create the configuration set
		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,
		});

        // Ensure we create the click distribution before the configuration set
		if (this.clickDistribution) {
			this.configSet.node.addDependency(this.clickDistribution);
		}

		// Create the domain identities
		this.identities = props.domains.map((identity) =>
			this.createDomainIdentity(identity, this.configSet)
		);

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

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

		// Create the SQS queue for notifications
		const queueName = `${name}-email-notifications`;
		const notificationQueue = new Queue(this, queueName, {
			queueName,
			enforceSSL: true,
			removalPolicy,
		});

		// Subscribe the queue to the topic
		eventTopic.addSubscription(new SqsSubscription(notificationQueue));

		this.eventQueue = notificationQueue;
	}
}
export class SES extends Construct {
    // ...

    private createDomainIdentity(
		{ zone, domain, domainFromPrefix, ruaEmail }: 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}; `;
		}

		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;
	}
}
Marcin Praski
Written by

Software engineer and founder. I enjoy designing distributed systems, and hopefully using them to solve real business problems.