EM Business Products Corp.
  • Services
  • Case studies
  • Blog
  • Connect with us
EM Business Products Corp.
  • Services
  • Case studies
  • Blog

12/18/2022

How to set up an Amazon SES transactional emailer with a custom template (part 3) -- Managing templates with a CLI

#aws
#nodejs
How to set up an Amazon SES transactional emailer  with a custom template (part 3) -- Managing templates with a CLI

Overview

What we did already

This is part 3 of our 3 part series for setting up our Amazon SES transactional emailer. In part 1, we configured Amazon SES to properly send emails from our custom domain. In part 2, we used the Amazon SES client library and the MJML library to build and send custom HTML templates.

What we’ll do here

In this part, we will organize everything we learned into a simple CLI that will let us manage our templates as needed in a project. Let’s get started!

Requirements

As noted in part 2 of this series, we are working off the very cool Node and Typescript starter project from https://khalilstemmler.com/, for a very clean Typescript Node project we can work off. We added some custom .mjml copy commands to our build and we also created a service class to expose our Amazon SES API methods.

We will continue to use this project to build our CLI.

💡 Just want the code? Here is a link to the Github project for this part of the series.

Let's get started

Create a CLI script to create our templates

A CLI is simply a program that you control on the terminal/shell of your OS. This will make it much easier for us to control creation, updating, and deleting our custom templates on Amazon SES.

We will create CLI scripts we can run to do the following:

  1. Convert an .mjml template into HTML and provide it to our Amazon SES template creation method.
  2. Create a .json version of our Amazon SES template data to create a trackable representation of our SES templates in our project repository.
  3. Create the Amazon SES template.
  4. Optionally update the Amazon SES template, if the template name already exists.
  5. Delete an Amazon SES template we no longer want to track.

Turn off console.log rule

The Node and Typescript starter project by default will issue a warning when using console.log statements. We will be adding some console.log statements in our CLI scripts to render messages in our shell, so if you’d like to remove the warnings displayed for console.log calls I recommend you disable the ES Lint rule.

To do so, open the .eslintrc file and change the "no-console": 1 to read "no-console": "off".

Initial setup for create template

Add a file called create-template.ts file within the src directory.

Add a new script to package.json below the "start" script with this key and value:

"start:create-template": "npm run build && node build/create-template.js"

We will use this command to run our template creation CLI script.

Import our needed dependencies

We will be using Node.js’s built-in capabilities to create the CLI. To do this we’ll need to import the readline module. Add this import to the top of create-template.ts:

import readline from "readline";

We will also be saving a .json file version of our Amazon SES template to disk to track changes on it in our repo. We will be using Node.js’s file system and path modules to help us do this. Let’s import those as well:

import path from "path";
import readline from "readline";
import fs from "fs";

We can now configure our readline module for our needs. Right below the imports, add this configuration:

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

The above creates the readline instance we will use for our CLI input/output.

Create helper functions

With the imports and readline instance created, we are now ready to create some helper functions we will be using to make our code a little cleaner.
The question helper function
In our first helper function we will be utilizing our readline instance (the rl constant we defined above) to call the question method of that instance. The question method is in charge of displaying a question to the user for our CLI, where they can enter an input.
The question method’s callback handler then provides that input to our code where we can use it. We will wrap it around a Promise to simplify its use:

import path from "path";
import readline from "readline";
import fs from "fs";
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
const question = (prompt: string): Promise<string> => {
  return new Promise((resolve) => {
    rl.question(prompt, resolve);
  });
};

💡 Making use of this promisified helper function for our CLI questions below will help us avoid callback hell by keeping our code shallow and readable.

The writeJsonTemplateFile helper function
Our second helper function will help us write our template data to a .json file. This helps us track the template data of our Amazon SES templates, as I mentioned earlier.
The function takes in the template name (which we will enter in the CLI) and the template data which at this point should contain the entire data object including the HTML template markup.
Here’s the function along with the previous code we added:

import path from "path";
import readline from "readline";
import fs from "fs";
import { Template } from "@aws-sdk/client-ses"; // <-- Add this import
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
const question = (prompt: string): Promise<string> => {
  return new Promise((resolve) => {
    rl.question(prompt, resolve);
  });
};
const writeJsonTemplateFile = (templateName: string, template: Template) => {
  const jsonDir = `${__dirname}/../src/json`;
  const jsonPath = `${jsonDir}/${templateName}.json`;
  // If the json directory does not yet exist, then create it
  if (!fs.existsSync(path.resolve(jsonDir)))
    fs.mkdirSync(path.resolve(jsonDir));
  fs.writeFileSync(path.resolve(jsonPath), JSON.stringify(template));
};

Set up for the CLI code

Now we are ready to start writing our CLI code. Below the helper functions, write an empty, self executing asynchronous function, like this:

//...your imports and helper functions here
(async () => {
  // We will write our CLI code in here
})();

Write the first CLI question

The question is a confirmation that you want to create a template. This helps in case you run the script by accident. You will have to type in ‘y’ to accept, or hit enter. The capital ‘Y’ in the question will indicate to users it is the default answer. Typing ‘n’, or anything else other than ‘y’, will close the CLI:

//...your imports and helper functions here
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
})();

As you can see we are using our question helper function we created earlier. We will use it each time we want to display a question to the user of our program.
The val variable will store the user’s input in the CLI. We check if the value is empty OR if it is equal to ‘y’. If either of those is true, the CLI program will not close. If both are false, the CLI program will close.

Test the first CLI question

Let’s make sure we have a working CLI so far by testing our first question. To do this, run npm run start:create-template or yarn start:create-template (depending on what package manager you are using) from your project’s root directory.
You should be prompted with the question:

This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n)

Type ‘n’ and press enter. The CLI should close.
Try it again and this time type ‘y’ then enter. The CLI should remain running.
To close the program, just press ctrl + c in your terminal or close the terminal window.
Now try it one more time. And this time just press enter. The CLI should remain running since an empty value will be accepted as a ‘y’ input.

Write CLI questions to get the email template info

We will now add more questions that will give us info on what MJML template file to use for our email, and the email subject line:

//...your imports and helper functions here
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const mjmlpath = await question(
    'Enter .mjml file path relative to "src/mjml" directory, including extension '
  );
  const subject = await question("Enter subject line ");
  console.log(`Will generate template from '${mjmlpath}'`);
  console.log(`Template's subject line will be '${subject}'`);
})();

Once these questions are displayed the user’s input is captured and saved into their respective variables (mjmlpath and subject). We’ll also get some messages printed to the shell to confirm the program received the correct input with the console.log lines.
Test that this works as expected and let’s move to the next step — loading the .mjml template.

Load the .mjml template file

We’ll now add a readFileSync call with our filesystem module to load the requested .mjml file:

//...your imports and helper functions here
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const mjmlpath = await question(
    'Enter .mjml file path relative to "src/mjml" directory, including extension '
  );
  const subject = await question("Enter subject line ");
  console.log(`Will generate template from '${mjmlpath}'`);
  console.log(`Template's subject line will be '${subject}'`);
  const mjMail = fs.readFileSync(
    path.join(__dirname, "mjml", mjmlpath),
    "utf8"
  );
})();

Convert the MJML markup to HTML

We now have a variable (mjMail) that holds our loaded template in memory. Now we can convert this data into HTML that can be set to our Amazon SES template object. First, let’s import the mjml2html function:

import mjml2html from "mjml";

Then we can make use of the mjml2html function to convert the .mjml template to HTML:

//...your imports and helper functions here
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const mjmlpath = await question(
    'Enter .mjml file path relative to "src/mjml" directory, including extension '
  );
  const subject = await question("Enter subject line ");
  console.log(`Will generate template from '${mjmlpath}'`);
  console.log(`Template's subject line will be '${subject}'`);
  const mjMail = fs.readFileSync(
    path.join(__dirname, "mjml", mjmlpath),
    "utf8"
  );
  const { html } = mjml2html(mjMail);
})();

Above, we get the HTML by utilizing the desctructured html var from the mjm2html call.

Get a text-only version of the template

Some email clients may have HTML disabled. This will keep our HTML template from displaying. To allow our content to be seen without HTML, we should provide the text-only version of our template. We will use html-to-text to do this next. Import the convert function from the html-to-text library:

import { convert } from "html-to-text";

Add the code needed to convert the HTML:

const text = convert(html, {
  wordwrap: 130,
});

Here’s what we have:

//...your imports and helper functions here
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const mjmlpath = await question(
    'Enter .mjml file path relative to "src/mjml" directory, including extension '
  );
  const subject = await question("Enter subject line ");
  console.log(`Will generate template from '${mjmlpath}'`);
  console.log(`Template's subject line will be '${subject}'`);
  const mjMail = fs.readFileSync(
    path.join(__dirname, "mjml", mjmlpath),
    "utf8"
  );
  const { html } = mjml2html(mjMail);
  const text = convert(html, {
    wordwrap: 130,
  });
})();

The text-only version is stored in the text var.

Create the Amazon SES Template object

We now have the data we need to create our Amazon SES Template config object. Let’s add it now:

//...your imports and helper functions here
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const mjmlpath = await question(
    'Enter .mjml file path relative to "src/mjml" directory, including extension '
  );
  const subject = await question("Enter subject line ");
  console.log(`Will generate template from '${mjmlpath}'`);
  console.log(`Template's subject line will be '${subject}'`);
  const mjMail = fs.readFileSync(
    path.join(__dirname, "mjml", mjmlpath),
    "utf8"
  );
  const { html } = mjml2html(mjMail);
  const text = convert(html, {
    wordwrap: 130,
  });
  const templateName = path.basename(mjmlpath, ".mjml");
  console.log(`Will create a template with name of '${templateName}'`);
  const template: Template = JSON.parse(
    JSON.stringify({
      TemplateName: templateName,
      SubjectPart: subject,
      HtmlPart: html,
      TextPart: text,
    })
  );
})();

Above, we first extracted a template name from the template path provided by the CLI input (const templateName = path.basename(mjmlpath, '.mjml');). This will be used as Amazon SES’s template name.
We print a message to the user with console.log to give them a confirmation of the template name that will be created.
We define an object and assign the template name, the email subject line, the converted HTML body we got from the .mjml template, and the text version of the body.
We also have a somewhat odd use of the JSON.stringify and JSON.parseutility methods. I will discuss why we used these next.

Ensuring properly escaped HTML entities

The HTML result we get from our mjml2html call won’t work properly with the way the Amazon SES template object expects the HtmlPart value to be provided. The HTML entities are not escaped properly for a JSON input, and creating the SES template will result in an error. To properly escape the HTML markup, we use the JSON.stringify and JSON.parse ”hack” to escape the HTML as needed. This is a very simple way to escape the HTML.

Create the Amazon SES template

With our template object set up and HTML properly escaped, we are now ready to make the Amazon SES service method call to create the template.
First, import the SesService class we created back in part 2 of this series:

import SesService from "./ses-service";

Now let’s create and instance of the service and create the template:

//...your imports and helper functions here
(async () => {
  //..our previous code
  //Template object we created earlier
  const template: Template = JSON.parse(
    JSON.stringify({
      TemplateName: templateName,
      SubjectPart: subject,
      HtmlPart: html,
      TextPart: text,
    })
  );
  const service = new SesService();
  try {
    const result = await service.createTemplate(template);
    console.log(result);
    rl.close();
  } catch (e) {
    console.log(e);
    rl.close();
  }
})();

Above, we call service.createTemplate and pass the template object to it. We then call rl.close() to close the program on a successful call. If there is an error it will be caught by our catch clause and the error is printed to the user. We also close the program on an error.
The above takes care of creating a template. But what about updating a template you had already created previously? Let’s handle that next.

Update an existing SES template

If we run the program on an existing template, Amazon SES will throw an error. This means our program will simply print the error and close. Instead of closing on an existing template, we want to give the user the option to update it.
To do this we’ll first need to check what error was thrown by Amazon SES, and if the error is for a template that already exists, ask the user if they want to update the template or cancel.
Let’s update our catch clause to this:

import { AlreadyExistsException } from "@aws-sdk/client-ses"; // <-- Add this import
//...your imports and helper functions here
(async () => {
  //..our previous code
  //Template object we created earlier
  const template: Template = JSON.parse(
    JSON.stringify({
      TemplateName: templateName,
      SubjectPart: subject,
      HtmlPart: html,
      TextPart: text,
    })
  );
  const service = new SesService();
  try {
    const result = await service.createTemplate(template);
    console.log(result);
    rl.close();
  } catch (e) {
    if (e instanceof AlreadyExistsException) {
      const doUpdate = await question(
        "Template already exists. Update? (Y/n) "
      );
      const shouldDoUpdate =
        doUpdate.length === 0 || doUpdate.toLowerCase() === "y";
      if (!shouldDoUpdate) return rl.close();
      const result = await service.updateTemplate(template);
      console.log(result);
      rl.close();
    } else throw e;
  }
})();

Above, we check if the error received is an instance of AlreadyExistsException. If so we display a question on our CLI and check for confirmation. If user confirms with ‘y’ then we call the service.updateTemplate method to update the template.
If the error is not an instance of AlreadyExistsException, we simply re-throw the error. Alternatively, you could console.log the error instead of re-throwing, as we had done previously.

Track the Amazon SES template config

We are almost done with our CLI program. I noted earlier in the article we would track the template that was created/updated as a .json file. We’ll set that up now.
We already created the helper function for this part, so it’s just a matter of making use of it.
We’ll want to place writeJsonTemplateFile(templateName, template); in 2 spots:
After a successful creation of the template and after a successful update of the template:

//...your imports and helper functions here
(async () => {
  //..our previous code
  //Template object we created earlier
  const service = new SesService();
  try {
    const result = await service.createTemplate(template);
    console.log(result);
    writeJsonTemplateFile(templateName, template); // <-- add here
    rl.close();
  } catch (e) {
    if (e instanceof AlreadyExistsException) {
      const doUpdate = await question(
        "Template already exists. Update? (Y/n) "
      );
      const shouldDoUpdate =
        doUpdate.length === 0 || doUpdate.toLowerCase() === "y";
      if (!shouldDoUpdate) return rl.close();
      const result = await service.updateTemplate(template);
      console.log(result);
      writeJsonTemplateFile(templateName, template); // <-- add here
      rl.close();
    } else throw e;
  }
})();

The JSON file will be created now.

The full code

Here’s the entire create-template.ts file:

import path from "path";
import readline from "readline";
import fs from "fs";
import { Template, AlreadyExistsException } from "@aws-sdk/client-ses";
import mjml2html from "mjml";
import { convert } from "html-to-text";
import SesService from "./ses-service";
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
const question = (prompt: string): Promise<string> => {
  return new Promise((resolve) => {
    rl.question(prompt, resolve);
  });
};
const writeJsonTemplateFile = (templateName: string, template: Template) => {
  const jsonDir = `${__dirname}/../src/json`;
  const jsonPath = `${jsonDir}/${templateName}.json`;
  // If the json directory does not yet exist, then create it
  if (!fs.existsSync(path.resolve(jsonDir)))
    fs.mkdirSync(path.resolve(jsonDir));
  fs.writeFileSync(path.resolve(jsonPath), JSON.stringify(template));
};
(async () => {
  const val = await question(
    "This script will create an Amazon SES email template to be used with the SES service. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const mjmlpath = await question(
    'Enter .mjml file path relative to "src/mjml" directory, including extension '
  );
  const subject = await question("Enter subject line ");
  console.log(`Will generate template from '${mjmlpath}'`);
  console.log(`Template's subject line will be '${subject}'`);
  const mjMail = fs.readFileSync(
    path.join(__dirname, "mjml", mjmlpath),
    "utf8"
  );
  const { html } = mjml2html(mjMail);
  const text = convert(html, {
    wordwrap: 130,
  });
  const templateName = path.basename(mjmlpath, ".mjml");
  console.log(`Will create a template with name of '${templateName}'`);
  const template: Template = JSON.parse(
    JSON.stringify({
      TemplateName: templateName,
      SubjectPart: subject,
      HtmlPart: html,
      TextPart: text,
    })
  );
  const service = new SesService();
  try {
    const result = await service.createTemplate(template);
    console.log(result);
    writeJsonTemplateFile(templateName, template);
    rl.close();
  } catch (e) {
    if (e instanceof AlreadyExistsException) {
      const doUpdate = await question(
        "Template already exists. Update? (Y/n) "
      );
      const shouldDoUpdate =
        doUpdate.length === 0 || doUpdate.toLowerCase() === "y";
      if (!shouldDoUpdate) return rl.close();
      const result = await service.updateTemplate(template);
      console.log(result);
      writeJsonTemplateFile(templateName, template);
      rl.close();
    } else throw e;
  }
})();

To test, just run npm run start:create-template or yarn start:create-template. We are done with the template creation CLI script! 🥳

Deleting the template

Create a CLI script to delete a template

Add a file called delete-template.ts file within the src directory.
Add a new script to package.json with this key and value:

"start:delete-template": "npm run build && node build/delete-template.js"

We will use this command to run our template delete CLI script.

Set up for the CLI code

Now we are ready to start writing our CLI code. The script for deleting a template is much simpler so I will paste the entire code:

import path from "path";
import readline from "readline";
import fs from "fs";
import SesService from "./ses-service";
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});
// Helper function to display questions
const question = (prompt: string): Promise<string> => {
  return new Promise((resolve) => {
    rl.question(prompt, resolve);
  });
};
(async () => {
  const val = await question(
    "This script will DELETE an Amazon SES email template from your AWS account. Are you sure you want to continue? (Y/n) "
  );
  const shouldContinue = val.length === 0 || val.toLowerCase() === "y";
  if (!shouldContinue) return rl.close();
  const name = await question("Enter template name ");
  try {
    // Remove .json file
    const jsonDir = `${__dirname}/../src/json`;
    const jsonPath = `${jsonDir}/${name}.json`;
    fs.unlinkSync(path.resolve(jsonPath));
    // Remove SES template
    const service = new SesService();
    const result = await service.deleteTemplate(name);
    console.log(result);
    rl.close();
  } catch (e) {
    console.log(e);
    rl.close();
  }
})();

As you can see here, we also have the question helper function to help us print questions to the shell. We then ask a confirmation question, like we did in the creation script. After that we ask for the template name to delete. This name is the one saved on the Amazon SES template, and the name of the .json file that is saved when creating a template.
We use fs.unlinkSync to delete the .json template file, then call service.deleteTemplate to delete the template.

Conclusion

That’s it! I hope this helps you understand the entire process for creating your own transactional emailer with Amazon SES. See you next time.

Like this article? Cool! You may like these:

CONNECT WITH US

Full Name

Email address

Message

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.