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