Setting Up SES With Pulumi

Categories

This post still working for you?

It's been a while since this was posted. Hopefully the information in here is still useful to you (if it isn't please let me know!). If you want to get the new stuff as soon as it's out though, sign up to the mailing list below.

Join the Mailing list
New Pulumi logo + AWS logo

tl;dr - Steps on how to set up SES with Pulumi (their brand new logo is the one above), an infrastructure-as-code (written in code) solution. Skip to the end for all the code, all at once

UPDATE (08/16/2021)

SES no longer requires separate domain identitify verification -- this is now integrated with the DKIM records you build for SES. You do not need the aws.ses.DomainIdentityVerification Pulumi object at all anymore -- the post has been updated to reflect that.

As Terraform gets most of the press – arguably deservedly so as Hashicorp is a great company and they make great tools – but my favorite tool in the declarative infrastructure-as-code space is Pulumi. Pulumi dared to be different when DSLs were kinda hot (maybe I’m the only one imagining it this way) but daring to do the work to stand slightly apart from Terraform (though they heavily reuse providers, because it’d be insane to not do that) and directly use code to do infrastucture as code was visionary. On the surface Pulumi might look like merely an also-ran but it looks like a GitHub/GitLab in a good way – GitLab started as a clone but has dropped a lot of innovative features first – Kanban-style boards, built-in CI, deployment, etc.

As for some concrete reasons I think Pulumi has the edge:

  • Power pure unbridled programming language power
  • Low barrier-to-entry custom resources (better abstraction and encapsulation, think “compound resources”)

Anyway, Terraform has a CDK now (even AWS has one), but I still recommend Pulumi to friends over Terraform because althrough terraform did a ton to move the industry forward, Pulumi was absolutely right in their recognition of the value in as-code infrastructure management. I rarely see people go into exactly how they’re using the tools and how they solve certain problems, so this post is basically a walkthrough of how you might actually set up SES on AWS with pulumi, starting from domain registration.

Along with domain registration, this post will also dipo a little into the email alphabet soup – SMTP, IMAP, DomainKeys Identified Mail (DKIM), Sender Policy Framework (SPF), Domain-based Message Authentication, Reporting and Conformance (DMARC), etc – because it’s something I got very interestd in back when I was running my own mail server. I use ProtonMail now because I got tired of friends making fun of me and I love it but I’m sure I’ll find myself maintaining my own mail servers again soon. There are projects out there like maddy which got further than I ever did that are delightfully easy to run. Highly tech-literate people should basically all know how to use mail servers (it’s almost like knowing how to send letters), and not enough people learn which is why we have to deal with walled gardens. Anyway, I’m digressing so let’s keep this post on track.

A few things before we get started:

  • Throughout the post I’ll use domain.tld to represent the theoretical domain you’re setting up
  • TLD = Top Level Domain
  • IaC = Infrastructure as Code

Getting a domain

Before we can serve receive or send email for our imaginary domain domain.tld, we need to purchase the right to direct users to servers we control when they type domain.tld into their browsers – we need to register our Domain Name System (DNS).

Purchase a domain (manual)

Starting a post about IaC with a manual step is a bit unfortunate, but I have to be honest I generally do this manually. Most tools don’t let you do this as it incurs charges/requries a 12-month+ commitment but let’s imagine you did.

There are lots of DNS providers out there, here are a few in no partcular order:

Recently I’ve been looking at other providers though, and have started working in AWS Route53. The reason for my wandering eyes is that Gandi doesn’t support GeoDNS. As I want to be able to build global software (and run in a geo-distributed manner, even if it’s without AWS or some other provider), it’s important to me that I be able to redirect traffic to different VPSes or dedicated hardware in given geographic areas. There are generally two ways to do this:

  • DNS Anycast - making sure the same IP points at different machines depending on where you ask
  • GeoDNS - detect the likely country of origin by based on the IP address

Anyway, Gandi offers neither of these and so I was basically down to either Route53 or Cloudflare (I like the services they offer along with DNS), and I went with Route53 for now.

For the rest of the guide we’ll assume you were able to get domain.tld.

(optional) Set up a DNS records for your specific stack

// <stack>.domain.tld -> k8s server
const stackRecord =  new aws.route53.Record(
  `${resourcePrefix}`,
  {
    name: `${stack}.${baseDomain}`,
    type: "A",
    allowOverwrite: true,
    ttl: 3600,
    zoneId: zone.zoneId,
    records: serverIps,
  },
);

export const stackFQDN = stackRecord.fqdn;

I personally like the <environment>.domain.tld setup for my projects (and clients) so a stackFQDN for staging would be staging.domain.tld. I like this approach because I can have all the things related to that environment be off that domain (ex. api.staging.domain.tld or www.staging.domain.tld).

As you might have guessed, emails for all “stack” (environment) will be coming from someone@<stack>.domain.tld. In the production case since we don’t want to send from someone@production.domain.tld (though actually we could, and just do an alias thing), we’ll detect when stack === "production" and do the appropriate replacement.

Create your apex domain

const apexRecord =  new aws.route53.Record(
  `${resourcePrefix}-apex`,
  {
    name: baseDomain,
    type: "A",
    zoneId: zone.zoneId,
    setIdentifier: `${resourcePrefix}-nogeo`,
    geolocationRoutingPolicies: [
      {continent: "", country: "*", subdivision: "*" },
    ],
    aliases: [{
      evaluateTargetHealth: false,
      name: `${stack}.${baseDomain}`,
      zoneId: zone.zoneId,
    }],
  }
);

export const apexFQDN = apexRecord.fqdn;

Along with the (non-production) environment specific domain, we’ll want an apex domain (ex. domain.tld) set up to handle the traffic heading directly to domain.tld. I’ve included a geolocationRoutingPoliciies section so I can do some Geo-based routing (there’s some other code not shown here that makes use of it), but you don’t have to – feel free to adapt this bit of code to fit your environment.

Setting up email

OK finally with the DNS stuff out of the way, let’s start setting up actual email

Set IAM user(s) to send emails with

// Common setup that is normally in another file called "common" but it's been shown here for completeness
const stack = pulumi.getStack();
const config = new pulumi.Config();

export const baseDomain = config.require("base-domain"); // this is "domain.tld"
export const resourcePrefix =  [ baseDomain, stack ].join("-"); // i.e. "domain.tld-production"

// IAM email identity
const emailUsername = `${resourcePrefix}-email`; // i.e. "domain.tld-production-email"
const emailUser = new aws.iam.User(
  `${resourcePrefix}-ses`,
  {
    name: emailUsername,
    path: "/system/",
    tags: { stack }
  },
);

export const emailUserARN = emailUser.arn;
export const emailUserID = emailUser.id;
export const emailUserUniqueID = emailUser.uniqueId;

OK, so it’s pretty simple, but what we’ve done here is we’ve set up a new IAM system user which we’ll use later to send emails with. You probably don’t want to set the IAM Path to /system/ but I like it because it lets me know that it’s a user which I expect to never log in.

Constrain the access of the email-sending user

// Generate the addresses that the user is allowed to send from
const allowedFromAddress = stack === "production" ? `*@${baseDomain}` : `*@${stack}.${baseDomain}`;

// Policy
const emailUserPolicy = new aws.iam.UserPolicy(
  `${resourcePrefix}-ses-policy`,
  {
    user: emailUser.name,
    policy: JSON.stringify({
      Version: "2012-10-17",
      Statement: [
        {
          Action: [
            "ses:SendEmail",
            "ses:SendTemplatedEmail",
            "ses:SendRawEmail",
            "ses:SendBulkTemplatedEmail",
          ],
          Effect: "Allow",
          Resource: "*",
          Condition: {
            StringLike: {
              "ses:FromAddress": allowedFromAddress,
            }
          }
        }
      ]
    }, null, '  '),
  },
);

export const emailUserPolicyID = emailUserPolicy.id;

Now that we’ve got a user that will theoreticallys end the emails, let’s make sure that the user can only send from emails that fit a certain mold. Here we can theoretically stop a compromise of a staging environment from sending email as the main environment or vice versa (and also, it won’t have access to send as absolutely anything you have hooked up to SES.

Create an IAM Access Key for the email user

export const emailUserPolicyID = emailUserPolicy.id;

// Email Access key
const emailAccessKey = new aws.iam.AccessKey(
  `${resourcePrefix}-ses-access-key`,
  {user: emailUser.name}
);

export const emailUserSMTPPassword = emailAccessKey.sesSmtpPasswordV4;
export const emailUserSecret = emailAccessKey.encryptedSecret;

Nothing too amazing here, the code speaks for itself! One thing to note is that sesSmtpPasswordV4 was actually broken for a time, but it works great as of the writing of this article.

SIDENOTE: Can we stop pretending AWS Access Key ID and Secret Access Keys are not usernames & passwords?

I always forget which way around they are (I even had to double check this time) – but it’s really bullshit that AWS decided to name these so badly. CLEARLY they are generated usernames and passwords. Coming up with confusing names did nothing to improve AWS’s security posture (or yours).

Writing out the email credentials to a file

// Write the smtp username and password out to a local secret file
const apiSecretsDir = path.join(__dirname, "secrets", stack, "aws");
const smtpUsernameFilePath = path.resolve(path.join(apiSecretsDir, "SES_USERNAME.secret"));
const smtpPasswordFilePath = path.resolve(path.join(apiSecretsDir, "SES_PASSWORD.secret"));

pulumi.all([
  emailAccessKey.id,
  emailAccessKey.sesSmtpPasswordV4,
]).apply(([accessKeyId, password]) => {
  pulumi.log.info(`Writing SES SMTP username (access key ID) to [${smtpUsernameFilePath}]`);
  fs.writeFileSync(smtpUsernameFilePath, accessKeyId);

  pulumi.log.info(`Writing SES SMTP password to [${smtpPasswordFilePath}]`);
  fs.writeFileSync(smtpPasswordFilePath, password);
});

OK, now we’re on to the somewhat interesting stuff. This chunk of code expects a directory (./secrets/stack/aws) to exist, and it attempts to write the files SES_USERNAME.secret and SES_PASSWORD.secret into that directory. The code uses pulumi.all to resolve some variables, and then uses fs.writeFileSync (sync code in NodeJS is yucky, forgive me) to write that content out to disk.

A few quick notes about my setup/what you might need to change:

  • Since git supports symlinks, ./secrets actually points to a folder under which secrets for this repo live
  • There are lots of ways to protect secrets stored in your repo
    • git-crypt (I use this, and am very happy with it – could use support for groups though)
    • sops
    • blackbox
  • If you still can’t trust in-repo secret storage, mount your KMS contents to disk locally with some FUSE thing (I don’t know if this exists, but it should)
  • If you don’t like the secrets even touching the disk, then use your KMS from the script!

SIDENOTE: Why you should use generic SMTP credentials instead of per-provider SDKs

I’m a fan of using SMTP access credentials rather than some AWS-specific programming language SDK, because it’s easier to swap out AWS when I need to (like if I run my own mail server, or choose to use MailGun or whatever else). This also comes up if you want to mock your email interactions locally while doing development – there’s some great software out there that makes it quite easy:

If you’re unix container-literate then you will also find it exceedingly easy to use these in testing usecases – so now you can write E2E testcases that are a little more close to real life!

Anyway, what we want to do is simple, we just need to write the credentials generated when the SES account is created to a file. To be fair, this is fairly easy to do in Terraform as well, but if your proficient with Javascript or Python or any other of Pulumi’s supported languages, it’s even easier with Pulumi (and you can accordingly write this information anywhere you want! Maybe to S3? FTP? Email? the possibilities are endless, because it’s just code).

A few things I needed to take into account for my own setup (these probably don’t apply to you, but you may have some similar concerns):

  • We’re automating our deployment too (k8s), and we’re using kustomize in particular
  • We encrypt in-repo secrets (this is somewhat transparent to operators)
  • Pulumi must now run first (at least once) before our k8s stuff

In my case the best way to easily do this is to just make Pulumi spit out the credentials to a well-known path (that the rest of the automation will refer to). There are at least two ways to do this with Pulumi:

Anyway, you can see I chose the second choice here. How I did it may not be best practice with Pulumi, but it works for me and doesn’t feel like too much of a hack. I also used fs-extra which is a (NodeJS) crowd favorite.

Set up DNS (Route53) to support sending emails

You thought the talk of DNS was over? Nope – we have the domain but we need to make records that appropriately represent our domain and how to send email/interact with it so we can properly send emails (and be good internet citizens).

Create an SES DomainIdentity

UPDATE: As noted above, you should no longer need the DomainIdentityVerification object! as Amazon has switched to using the DKIM records for that, here’s the quote from the ~horse~ cloud provider’s mouth:

Legacy TXT records

Domain verification in Amazon SES is now based on DomainKeys Identified Mail (DKIM), an email authentication standard that receiving mail servers use to validate an email’s authenticity. Configuring DKIM in your domain’s DNS settings confirms to SES that you’re the identity owner, eliminating the need for TXT records. Domain identities that were verified using TXT records do not need to be reverified; however, we still recommend enabling DKIM signatures to enhance the deliverability of your mail with DKIM-compliant email providers. To access your legacy TXT records, download the record set as a .csv

I’m leaving in the section below as it was in the original post, but note again that while you do want the aws.ses.DomainIdentity you do not need the aws.ses.DomainIdentityVerification. The verification will be performed by the aws.ses.DomainDkim record you make later!.

// In the shared code somewhere


// Domain Identity
const stackDomainIdentity = new aws.ses.DomainIdentity(
  `${resourcePrefix}`,
  { domain: stack === "production" ? apexFQDN : stackFQDN },
);

// (Prep for the) Verification record
const sesStackVerificationRecord = new aws.route53.Record(
  `${resourcePrefix}-ses-verification`,
  {
    zoneId: zoneID,
    name: `_amazonses.${stack}`,
    ttl: 3600,
    type: "TXT",
    records: [stackDomainIdentity.verificationToken],
  }
);

export const sesStackVerificationRecordID = sesStackVerificationRecord.id;


// (Do the) Verification

const stackVerification = new aws.ses.DomainIdentityVerification(
  `${resourcePrefix}-ses-verification`,
  { domain: stackDomainIdentity.id },
  {
    dependsOn: [ sesStackVerificationRecord ],
    customTimeouts: { create: "10s" },
  },
);

export const stackVerificationARN = stackVerification.arn;
export const stackVerificationID = stackVerification.id;

No one really directly discusses it (usually Terraform/Pulumi example text is good enough to figure it out) but one of the not-quite-well-discussed things you’ll need to send emails with SES is a DomainIdentity. Creating the DomainIdentity is only 1/3rd of the work though, you need to create a DNS record (since we’re on AWS, this means interacting with Route53) that shows AWS you have control of the domain in question.

You need a TXT record with the prefix _amazonses[.anything]. I’ve chosen to go with _amazonses.<environment> so that I can have both TXT records in place at the same time to service different environments. A regular DNS TXT record looks like this:

example.com.   IN   TXT   "This domain name is reserved for use in documentation"

Once this is created we need to create a DomainIdentityVerification object to trigger the actual check. AWS will do the work to verify that we own (or at least owned) the domain that we want to send emails for.

Note that if you had your domains managed elsewhere (GCP, Azure, Cloudflare, etc), then the verification record could be any other provider (whether managed by Pulumi or not, because again, it’s just code!). The only part you’d need to change is the sesStackVerificationRecord part because that pre-assumes aws.route53.Record is what you want.

What Domain Verification success looks like

Assuming you succeed, you should get an email that looks like the following from AWS:

SUBJECT Domain Verification SUCCESS for staging.domain.tld in region US West (Oregon) | BODY Congratulations! We successfully verified staging.domain.tld in region US West (Oregon). You are now able to send email through Amazon SES and Amazon Pinpoint from any address within this domain. For more information, please refer our Developer Guide.

Thank you for using Amazon Web Services.

OK so this is awesome, but just the first step! Now that we’ve done our domain validation

Setting up MAIL FROM

// MAIL FROM Domain (bounce.<stack>.domain.tld)
//
// NOTE: The MAIL FROM domain shouldn't actually be used to send or receive email,
// mail should be received/sent from <stack>.domain.tld
// https://docs.aws.amazon.com/ses/latest/DeveloperGuide/mail-from.html

const stackMailFrom = new aws.ses.MailFrom(
  `${resourcePrefix}-ses-mail-from`,
  {
    domain: stackDomainIdentity.domain,
    mailFromDomain: pulumi.interpolate`bounce.${stackDomainIdentity.domain}`,
  }
);

// MAIL FROM MX record
const stackMailFromMXRecord = new aws.route53.Record(
  `${resourcePrefix}-ses-mail-from-mx-record`,
  {
    zoneId: zoneID,
    name: stackMailFrom.mailFromDomain,
    type: "MX",
    ttl: 3600,
    records: [ `10 feedback-smtp.${sesRegion}.amazonses.com` ],
  }
);

export const stackMailFromMXRecordID = stackMailFromMXRecord.id;

MAIL FROM is exactly what it sounds like, a way to send email on behalf of another domain. I’ve never seen it explained in a non-confusing manner but hopefully this was good enough. You’ve probably noticed it in your email client of choice – when 981305123.mailprovider.com sends on behalf of coolapp.com. SES supports MAIL FROM, so it’s a good idea to set it up while we’re here.

To make it work we need two things – the MailFrom object which tells AWS about our intent to use a MAIL FROM address, and the Route53 record that sets up a MX record to make it happen. Along the way we use pulumi.interpolate to generate the value we needs.

Email “Deliverability” (i.e. how to tell other email servers you’re not a spammer)

OK cool, so we’ve got a lot of stuff set up! We’re basically ready to send emails – but there’s not much of a point to sending emails if they never get delivered! In addition to basic mail delivery technology (SMTP) and client-side email technology (POP3/IMAP) we need to be familiar with some of the standards/technologies around email sender validation/verification – or risk ending up in the spam box of other email servers/providers:

DomainKeys Identified Mail (DKIM)

const stackDomainDKIM = new aws.ses.DomainDkim(
  `${resourcePrefix}-ses-domain-dkim`,
  { domain: stackDomainIdentity.domain },
);

export const stackDomainDKIMTokens = stackDomainDKIM.dkimTokens;

const stackDKIMRecords: aws.route53.Record[] = [];
const dkimRecordCount = 3;

for (let i = 0; i < dkimRecordCount; i++) {
  const token = stackDomainDKIM.dkimTokens[i].apply(t => `${t}.dkim.amazonses.com`);
  const name = stackDomainDKIM.dkimTokens[i].apply(t => `${t}._domainkey.${stack}.${baseDomain}`);

  const dkimRecord = new aws.route53.Record(
    `${resourcePrefix}-dkim-record-${i + 1}-of-${dkimRecordCount}`,
    {
      zoneId: zoneID,
      name,
      type: "CNAME",
      ttl: 3600,
      records: [ token ],
    });

  stackDKIMRecords.push(dkimRecord);
}

DKIM was the first well-adopted answer in this space IIRC so it’s a good idea to read up on it if you haven’t already. The Pulumi docs on DomainDKIM are good as well.

Anyway, the code above is also pretty interesting! We’re doing something that was introduced to Terraform in 0.12, but has always available when you’re writing code – for loops! We need for loops because we need to create a DKIM record for every dkimToken output that DomainDkim produces. all said and done a few applys and a route53.Record is what we need.

What DKIM setup sucess looks like

If DKIM is set up successfully you should get an email that looks like this:

SUBJECT DKIM setup SUCCESS for staging.domain.tld in US West (Oregon) region BODY Congratulations! Your DKIM setup for the domain staging.domain.tld is complete. You can now send DKIM-signed emails from any address within this domain through both Amazon SES and Amazon Pinpoint.

Please note that the settings for individually verified email addresses override domain-level settings. For example, if you enable DKIM signing for a verified domain but disable DKIM signing for a verified email address in that domain, then emails from that email address will not be DKIM-signed.

For more information about DKIM, see the Amazon SES Developer Guide at http://docs.aws.amazon.com/ses/latest/DeveloperGuide/dkim.html .

Please note that this email only relates to the US West (Oregon) region.

Thank you for using Amazon Web Services.

Nice and easy!

Sender Policy Framework (SPF)

// Refer back to and use the DNS stuff to figure out which domain will be sending outgoing mail
const outgoingMailFQDN = stack === "production" ? apexFQDN : stackFQDN;

// SPF MX record
const stackSPFMXRecord = new aws.route53.Record(
  `${resourcePrefix}-ses-spf-mx-record`,
  {
    name: stackMailFrom.mailFromDomain,
    type: "TXT",
    ttl: 3600,
    zoneId: zoneID,
    // Allow email from amazonses.com and the stack's FQDN (ex. stack
    records: [ `v=spf1 include:amazonses.com mail -all` ],
  }
);

export const stackSPFMXRecordID = stackSPFMXRecord.id;

SPF came after DKIM and is more related to which domains can send email on a domain’s behalf (also worth reading up on). SPF records need to be formatted a very specific way so take some time to read up on how it works. I’ll do a quick breakdown for the lazy:

  • v=spf1 the version of structured TXT record we’re using (ex. v=DMARC is also a thing)
  • include:amazonses.com allows amazonses.com to send emails on our behalf
  • mail allows a server specified by an A/AAAA record at mail.domain.tld to send email on our behalf
  • -all disallows any not-listed addresses from sending emails on our behalf

OK, so I’m not going to go into all of what SPF is here, there’s a lot out there already, but it’s pretty straight forward when written out with Pulumi.

What SPF setup success looks like

As far as I remember there wasn’t any special email or anything for this – it’s not really a concept baked in to SES so there’s no special flow.

Domain-based Message Authentication, Reporting and Conformance (DMARC)

// DMARC MX record
const dmarcRecord = new aws.route53.Record(
  `${resourcePrefix}-external-dmarc`,
  {
    name: outgoingMailFQDN.apply(fqdn => `_dmarc.${fqdn}`),
    zoneId: zoneID,
    ttl: 3600,
    type: "TXT",
    records: [
      `v=DMARC1; p=none; rua=mailto:${adminEmailAddress}; fo=1;`,
    ],
  }
);

export const dmarcRecordID = dmarcRecord.id;

DMARC is another set of proceses/technology around reporting

What DMARC setup success looks like

While there isn’t anything AWS will send you in particular, setting this up will mean that whatever adminEmailAddress you’ve set up will get periodic emails relating to email delivery. You may want to use an email service that displays this information in a dashboard form – I actually think I might run one one of these days since it seems pretty useful (reach out if you’d use it, I’ll build it even quicker, and I’ll give it to at half price as an early adopter).

BONUS: Amazing guides from Pulumi

The Pulumi architecture documentation are pretty great (and where you should start), even though the docs in general can be hard to seach efficiently at times. Turns out there are some pretty great guides out there for doing things with Pulumi so I thought I’d highlight some of them (they’re all listed on the Tutorials section):

All the SES code, all at once

Here’s a code dump – normally it lives as ses.ts in one of my GitLab private repositories (which were free long before GitHub got infused with M$). For the cost of having some code that isn’t shown (ex. dns.ts and common.ts), you get a few extra features:

  • How to set up external SMTP server(s) (listening @ mail.<environmnt>.domain.tld) to work with SES so I can send email from either (and receive email)
  • How to set up a DKIM record in chunks for your external SMTP server (maddy in my case)
    • This was harder than I thought it was going to be, simply passing the contents in did not work. Maybe I did something wrong, but the code in there works

Anyway here’s the code:

import * as fs from "fs-extra";
import * as path from "path";
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

import { baseDomain, resourcePrefix, serverIps } from "./common";
import { zoneID, stackFQDN, apexFQDN } from "./dns";

///////////////////
// Configuration //
///////////////////

const stack = pulumi.getStack();
const config = new pulumi.Config();

const sesRegion = config.require("ses-region");
const adminEmailAddress = config.require("admin-email-address");

/////////
// IAM //
/////////

// IAM email identity
const emailUsername = `${resourcePrefix}-email`;
const emailUser = new aws.iam.User(
  `${resourcePrefix}-ses`,
  {
    name: emailUsername,
    path: "/system/",
    tags: { stack }
  },
);

export const emailUserARN = emailUser.arn;
export const emailUserID = emailUser.id;
export const emailUserUniqueID = emailUser.uniqueId;

const allowedFromAddress = stack === "production" ? `*@${baseDomain}` : `*@${stack}.${baseDomain}`;

// Policy
const emailUserPolicy = new aws.iam.UserPolicy(
  `${resourcePrefix}-ses-policy`,
  {
    user: emailUser.name,
    policy: JSON.stringify({
      Version: "2012-10-17",
      Statement: [
        {
          Action: [
            "ses:SendEmail",
            "ses:SendTemplatedEmail",
            "ses:SendRawEmail",
            "ses:SendBulkTemplatedEmail",
          ],
          Effect: "Allow",
          Resource: "*",
          Condition: {
            StringLike: {
              "ses:FromAddress": allowedFromAddress,
            }
          }
        }
      ]
    }, null, '  '),
  },
);

export const emailUserPolicyID = emailUserPolicy.id;

// Email Access key
const emailAccessKey = new aws.iam.AccessKey(
  `${resourcePrefix}-ses-access-key`,
  {user: emailUser.name}
);

export const emailUserSMTPPassword = emailAccessKey.sesSmtpPasswordV4;
export const emailUserSecret = emailAccessKey.encryptedSecret;

// Write the smtp username and password out to a local secret file
const apiSecretsDir = path.join(__dirname, "secrets", stack, "aws");
const smtpUsernameFilePath = path.resolve(path.join(apiSecretsDir, "SES_USERNAME.secret"));
const smtpPasswordFilePath = path.resolve(path.join(apiSecretsDir, "SES_PASSWORD.secret"));

pulumi.all([
  emailAccessKey.id,
  emailAccessKey.sesSmtpPasswordV4,
]).apply(([accessKeyId, password]) => {
  pulumi.log.info(`Writing SES SMTP username (access key ID) to [${smtpUsernameFilePath}]`);
  fs.writeFileSync(smtpUsernameFilePath, accessKeyId);

  pulumi.log.info(`Writing SES SMTP password to [${smtpPasswordFilePath}]`);
  fs.writeFileSync(smtpPasswordFilePath, password);
});

///////////////////
// SES / Route53 //
///////////////////

// Domain Identity
const stackDomainIdentity = new aws.ses.DomainIdentity(
  `${resourcePrefix}`,
  { domain: stack === "production" ? apexFQDN : stackFQDN },
);

// Verification record
const sesStackVerificationRecord = new aws.route53.Record(
  `${resourcePrefix}-ses-verification`,
  {
    zoneId: zoneID,
    name: `_amazonses.${stack}`,
    ttl: 3600,
    type: "TXT",
    records: [stackDomainIdentity.verificationToken],
  }
);

export const sesStackVerificationRecordID = sesStackVerificationRecord.id;

// Verification

const stackVerification = new aws.ses.DomainIdentityVerification(
  `${resourcePrefix}-ses-verification`,
  { domain: stackDomainIdentity.id },
  {
    dependsOn: [ sesStackVerificationRecord ],
    customTimeouts: { create: "10s" },
  },
);

export const stackVerificationARN = stackVerification.arn;
export const stackVerificationID = stackVerification.id;

///////////////
// MAIL FROM //
///////////////

// MAIL FROM Domain (bounce.<stack>.domain.tld)
//
// NOTE: The MAIL FROM domain shouldn't actually be used to send or receive email,
// mail should be received/sent from <stack>.domain.tld
// https://docs.aws.amazon.com/ses/latest/DeveloperGuide/mail-from.html

const stackMailFrom = new aws.ses.MailFrom(
  `${resourcePrefix}-ses-mail-from`,
  {
    domain: stackDomainIdentity.domain,
    mailFromDomain: pulumi.interpolate`bounce.${stackDomainIdentity.domain}`,
  }
);

// MAIL FROM MX record
const stackMailFromMXRecord = new aws.route53.Record(
  `${resourcePrefix}-ses-mail-from-mx-record`,
  {
    zoneId: zoneID,
    name: stackMailFrom.mailFromDomain,
    type: "MX",
    ttl: 3600,
    records: [ `10 feedback-smtp.${sesRegion}.amazonses.com` ],
  }
);

export const stackMailFromMXRecordID = stackMailFromMXRecord.id;

/////////
// SPF //
/////////

const outgoingMailFQDN = stack === "production" ? apexFQDN : stackFQDN;

// SPF MX record
const stackSPFMXRecord = new aws.route53.Record(
  `${resourcePrefix}-ses-spf-mx-record`,
  {
    name: stackMailFrom.mailFromDomain,
    type: "TXT",
    ttl: 3600,
    zoneId: zoneID,
    // Allow email from amazonses.com and the stack's FQDN (ex. stack
    records: [ `v=spf1 include:amazonses.com mail -all` ],
  }
);

export const stackSPFMXRecordID = stackSPFMXRecord.id;

////////////////
// DKIM - AWS //
////////////////

const stackDomainDKIM = new aws.ses.DomainDkim(
  `${resourcePrefix}-ses-domain-dkim`,
  { domain: stackDomainIdentity.domain },
);

export const stackDomainDKIMTokens = stackDomainDKIM.dkimTokens;

const stackDKIMRecords: aws.route53.Record[] = [];
const dkimRecordCount = 3;

for (let i = 0; i < dkimRecordCount; i++) {
  const token = stackDomainDKIM.dkimTokens[i].apply(t => `${t}.dkim.amazonses.com`);
  const name = stackDomainDKIM.dkimTokens[i].apply(t => `${t}._domainkey.${stack}.${baseDomain}`);

  const dkimRecord = new aws.route53.Record(
    `${resourcePrefix}-dkim-record-${i + 1}-of-${dkimRecordCount}`,
    {
      zoneId: zoneID,
      name,
      type: "CNAME",
      ttl: 3600,
      records: [ token ],
    });

  stackDKIMRecords.push(dkimRecord);
}

//////////////////
// DKIM - Maddy //
//////////////////

const emailDir = path.resolve(path.join(__dirname, "secrets", stack, "email"));

const dkimPublicKeyPath = path.join(emailDir, "dkim.public-key.pem");
pulumi.log.info(`Reading DKIM public key from  [${dkimPublicKeyPath}]`);

const dkimKeyText = fs.readFileSync(dkimPublicKeyPath)
  .toString('utf8')
  .replace("-----END PUBLIC KEY-----\n","")
  .replace("-----BEGIN PUBLIC KEY-----\n","")
  .replace(/\n/g,"")
  .replace(/\s+/g,"")
  .trim();

// Split the DKIM record into <255 char len strings
const dkimRecordChunks = [];
const goal = `v=DKIM1; k=rsa; p=${dkimKeyText}`;
for (var i = 0; i < goal.length; i += 253) { dkimRecordChunks.push(goal.substring(i, i + 253)); }

if (!dkimRecordChunks) { throw new Error("Failed to generate DKIM record chunks"); }

// DKIM MX record
const externalSMTPDKIMRecord = new aws.route53.Record(
  `${resourcePrefix}-external-smtp-verification`,
  {
    zoneId: zoneID,
    name: outgoingMailFQDN.apply(fqdn => `_mail._domainkey.${fqdn}`),
    ttl: 3600,
    type: "TXT",
    records: [ dkimRecordChunks.join('\"\"') ],
  }
);

export const externalSMTPDKIMRecordID = externalSMTPDKIMRecord.id;

///////////
// DMARC //
///////////

// DMARC MX record
const dmarcRecord = new aws.route53.Record(
  `${resourcePrefix}-external-dmarc`,
  {
    name: outgoingMailFQDN.apply(fqdn => `_dmarc.${fqdn}`),
    zoneId: zoneID,
    ttl: 3600,
    type: "TXT",
    records: [
      `v=DMARC1; p=none; rua=mailto:${adminEmailAddress}; fo=1;`,
    ],
  }
);

export const dmarcRecordID = dmarcRecord.id;

///////////////////
// External SMTP //
///////////////////

const externalSMTPRecord = new aws.route53.Record(
  `${resourcePrefix}-external-smtp`,
  {
    name: outgoingMailFQDN.apply(fqdn => `mail.${fqdn}`),
    zoneId: zoneID,
    ttl: 3600,
    type: "A",
    records: serverIps,
  }
);

export const externalSMTPRecordID = externalSMTPRecord.id;

// mail.domain.tld -> MAIL instance
const externalSMTPMMXRecord =  new aws.route53.Record(
  `${resourcePrefix}-external-mx`,
  {
    name: outgoingMailFQDN.apply(fqdn => `mail.${fqdn}`),
    type: "MX",
    allowOverwrite: true,
    ttl: 3600,
    zoneId: zoneID,
    records: [ pulumi.interpolate`10 ${externalSMTPRecord.fqdn}.` ],
  },
);

export const externalSMTPMMXRecordID = externalSMTPMMXRecord.id;

Wrapup

Well this code was fun to write and I’m glad I finally got around to making a blog post out of it. Hopefully you’ve got a good grasp on how you use Pulumi and learned just a bit more about email and how to get SES up and running.

Like what you're reading? Get it in your inbox