When building out applications, sending email is a frequently needed feature. In addition, it's likely that the data sent in the email needs to be stored in a database for record keeping, analytics, or additional processing.
AWS provides a range of services that help make setting up an API, database, and email transport quick, and secure. Specifically, AWS Amplify provides a lot of the functionality we'll need out of the box, and Amazon SES will send emails on our behalf.
Overview of Services
AWS Amplify is a suite of services ranging from UI components and a use case focused CLI to a CI/CD console and backend-scaffolding GUI.
On the other hand, Amazon SES provides a scalable solution for sending email and is used at companies like Reddit and Netflix.
In this post, we'll be using the Amplify CLI along with its JavaScript libraries to create a backend for a contact form, and use Amazon SES to send that information to our email.
Getting Started
If you don't already have an AWS account or have the Amplify CLI installed, follow this guide.
🚨 This project makes use of lambda environment variables. The ability to do this via the CLI was introduced in version
5.1.0
. You may need to runnpm install -g @aws-amplify/cli
to ensure you're on the latest version.
Once setup, clone this contact-form-starter
branch from this github url.
After cloning the project, install the dependencies and run the project. Below are some helpful commands:
// visit: https://github.com/mtliendo/amplify-email-recipes/tree/contact-form-starter
git clone git@github.com:mtliendo/amplify-email-recipes.git
cd amplify-email-recipes
git checkout contact-form-starter
npm install
npm run dev
Once the project has started, visit localhost:3000
and you should be presented with the following screen:
Understanding Our Backend
Using the above image as a reference, the Amplify services we'll need for our backend are in the following order:
AppSync: Fully managed GraphQL API
DynamoDB: NoSQL database
Lambda: FaaS/cloud function
In short, when a user fills out their contact form, that information will be stored in our database via our API. When that item is successfully saved, it will automatically trigger a function to send an email.
Sounds like a lot. Let's see what we have to do to get this working.
Initializing Our Backend 🚀
We'll start creating our backend by opening up a terminal and making sure we're in the root directory of our project.
From here, we'll initialize Amplify by running the following command:
amplify init
We'll give our project a name and when prompted, select n
to deny the default configuration. This is because we will be deploying our application as a static site. In NextJS, the name of that build directory is called out
.
In the terminal, accept all of the prompts, except when it comes to the Distribution Directory Path
enter out
.
The entire flow should look like the below screenshot:
Lastly, after selecting AWS profile
we'll choose the profile we'd like to use.
The flow should look similar to the following screenshot:
Adding An API
With our application ready to use the Amplify CLI, we'll create our backend. As mentioned earlier, we are using AWS AppSync, which is a managed GraphQL API.
Traditionally, when sending email, a REST API is used. However, I've found that as needs change, AppSync provides more flexibility when it comes to handling authorization and a few other features.
To add an API in Amplify, we'll simply type the following command in our project's terminal:
amplify add api
While in the CLI prompts, choose the following options:
GraphQL
[enter] to accept the default name
API key
"Contact form public API"
[enter] to accept a default of 7 days
[enter] to accept "No, I am done."
[enter] to accept the default "N" option
[enter] for a Single object with fields
"y" to edit the schema now
Select your editor of choice
By selecting those options through the prompts, we told Amplify how we would like our API to be built.
At this point, Amplify has opened up a file called schema.graphql
with a sample Todo object. Replace everything in that file with the following:
type Candidate
@model
@auth(rules: [{ allow: public, operations: [create] }]) {
id: ID!
name: String!
email: AWSEmail!
}
To break down what's happening here, we are first creating a type called Candidate
. In our application, a Candidate represents the user submitting their information.
The @model
text is called a directive. When Amplify sees this, it will automatically create a DynamoDB table and create CRUDL operations for the type it's associated with (in this case, Candidate).
The @auth
directive setups up authorization rules on our API. Here we are saying, "We want our API to be public to anyone with an API key, but we only want them to be able to create entries in out database, they can't read, update, or delete items.
The next few lines are the fields associated with a Candidate. Here it's required that every Candidate has a unique id (automatically created with ID
), a name, and an email -- AWS has a primitive called AWSEmail that automatically validates an email pattern.
With that, our API and database are ready to be deployed. Before doing so, let's move on to our function.
Setting Up Our Function Trigger
AWS Lambda is an event-driven function. Meaning, it is called as a response to something. Often times, this is an endpoint like /pets
, but in our application, we want this function to be called whenever an item is added to our database.
Fortunately, Amplify takes care of this process by allowing us to set this up from the CLI.
In our terminal, let's go through the following prompts:
amplify add function
Lambda function (serverless function)
"contactformuploader" as the name of the function
NodeJS
Lambda Trigger
Amazon DynamoDB Stream
Use API category graphql @model backed DynamoDB table in the current Amplify project
[enter] to not configure advanced settings
[enter] to edit the local function now
Choose your editor of choice
This will open up the function in your editor. Before we remove the contents, let's chat about the generated code.
When a change happens to a record in our database -- a change being either a INSERT
, MODIFY
, or REMOVE
event, that information is sent as a stream of data to our lambda function.
However, our database can undergo heavy traffic. So instead of firing our lambda for one change at a time, the changes can be sent in batches called shards. No need to get too technical, but this is why the generated code is iterating over event.Records
.
To drive the concept home, here's a diagram to showcase streaming and sharding:
With that mini-lesson out of the way, let's replace the content in our lambda function, with the following:
const aws = require('aws-sdk')
const ses = new aws.SES()
exports.handler = async (event) => {
for (const streamedItem of event.Records) {
if (streamedItem.eventName === 'INSERT') {
//pull off items from stream
const candidateName = streamedItem.dynamodb.NewImage.name.S
const candidateEmail = streamedItem.dynamodb.NewImage.email.S
await ses
.sendEmail({
Destination: {
ToAddresses: [process.env.SES_EMAIL],
},
Source: process.env.SES_EMAIL,
Message: {
Subject: { Data: 'Candidate Submission' },
Body: {
Text: { Data: `My name is ${candidateName}. You can reach me at ${candidateEmail}` },
},
},
})
.promise()
}
}
return { status: 'done' }
}
This function will be automatically called when a candidate submits their information. The event
will contain the related stream of data. So from here our job is simple:
Grab the items from the stream, and send an email.
Using the AWS SDK, we call the sendEmail
function from the ses
module.
Wit that out of the way, we now have at least touched on all the pieces of our backend. We still however have a couple loose ends.
Our function doesn't have permission to interact with SES
We need to setup this
process.env.SES_EMAIL
variableWe've yet to setup SES
Our frontend code isn't setup to interact with our backend.
Let's change gears for a bit and start with the third item and revisit the others.
Setting Up SES
As mentioned earlier, Amazon Simple Email Service (SES) provides a scalable way to send email. When first setting up SES, AWS will place you in sandbox mode.
This means we'll have the following constraints:
We can only send/receive to verified email addresses
We can only send 1 email/sec
Only 200 emails/day are allowed
Fortunately for our application, this won't matter too much.
To get started, let's hop into our terminal and run the following command:
amplify console
When prompted, select "Amplify console".
📝 you may be asked to log in to your AWS account
Once logged in, search for "SES" in the top search bar of the console and hit enter.
You should see a view similar to the one above. If not, you may need to click the top banner to be taken to this newer UI.
From here, perform the following steps:
Click the orange "Create identity" button
Select the "Email address" option and enter your desired email
Click the orange "Create identity" button
That's it! Setting up an email for this service is well...simple 😅
There are two things we'll need before we hop back into our code.
First, copy the ARN for your email by clicking the copy icon on the verified identities screen as show in the screenshot below:
Store that in a notepad. We'll need it in a bit.
Next, SES sent a confirmation email to the email address that was provided. Click the verification link and we're all set to head back to our code.
Updating Our Lambda
Recall that we need to both give our function permission to access SES, and add an environment variable to the function called SES_EMAIL
.
Let's first update the permissions.
In your project directory we'll want to navigate to the following directory:
amplify/backend/function/your-function-name/
Inside of this directory, you'll see the src
directory for lambda, and a file titled
your-function-name-cloudformation-template.json
Select this file.
No need to be intimidated, this JSON code is known as Cloudformation and is what Amplify has been creating for us when we were interacting with the CLI.
It's full of settings and rules and we're about to add one more.
Search for lambdaexecutionpolicy
(it should be right around line 132).
This object has a Statement
array that currently contains a single object. This object lets our function create logs in AWS.
Add the following object to the Statement
array and save:
{
"Action": ["ses:SendEmail"],
"Effect": "Allow",
"Resource": "the-arn-you-copied-from-ses"
}
This small addition gives our function the ability to call the sendEmail
function using the email we verified.
The lambdaexecutionpolicy
object should look like the below screenshot (note I removed my email in place of a *
for a bit more flexibility):
The next step is to add the environment variable to our function.
Back in the terminal, run the following command:
amplify update function
Enter the following options:
Lambda function (serverless function)
[Select your function name]
Environment variables configuration
type
SES_EMAIL
Enter the email that was verified with SES
I'm done
No -- I don't want to edit the local file now
Push Up Our Backend
We've done a lot by only running a few commands in the CLI. This templated our resources, but we have yet to push everything up to AWS.
Let's fix that by running the following command in the terminal:
amplify push
This will provide a table of the primary resources we created (recall that our database is a secondary resource created by the @model directive).
After selecting that you'd like to continue, select the following options:
Yes -- generate code for the API 🔥
JavaScript
[enter] to allow the default file path
Yes -- generate all possible operations (recall we only allow
create
per our schema)[enter] to accept a max depth of 2
It'll take a few minutes for our terminal to finish up, but once that's done, our backend is complete 🎉
Let's wrap this up by giving our frontend the ability to talk to our backend.
Configuring Amplify Libraries
We'll start off by installing the AWS Amplify JavaScript package:
npm i aws-amplify
Once that is installed, we'll marry our frontend and backend together. In _app.js
, add the following lines:
import Amplify from '@aws-amplify/core'
import config from '../src/aws-exports'
Amplify.configure(config)
Here we bring in the Amplify library, bring in our config (Amplify generated this and put it in .gitignore
), and then we pass in our config to Amplify.
Next up, in ContactForm.js
, we'll also bring in the following imports:
import { API } from 'aws-amplify'
import { createCandidate } from '../src/graphql/mutations'
📝 Feel free to check out the
createCandidate
mutations file that Amplify generated for us.
The API category is how we will talk to our AppSync API. Recall that this should not only store the contact in our database, but send an email to our verified address as well.
The ContactForm.js
file has the following lines of code:
// TODO: Add code to send email here
console.log('<send to backend here>')
Replace the above with this snippet:
await API.graphql({
query: createCandidate,
variables: {
input: {
name,
email,
},
},
})
With that bit out of the way, we can now test our project!
Restart your application on localhost:3000
and test it out. If all went well, after a few seconds you'll have an email in your inbox 🎉
📝 Because our emails are being sent via SES, they may show up in a spam folder or flagged by your email provider. This is because we haven't setup DKIM with SES. Though not terribly difficult, it's out of scope for this tutorial. However, if interested, you can read more about it here
Hosting Our Project
Having this run locally is great, but for a contact form, chances are we want it to be live on the internet.
Fortunately, Amplify allows us to do this from the CLI as well.
To get started, in our terminal, we'll run the following command:
amplify add hosting
From the prompts, we'll select the following options:
Hosting with Amplify Console
Manual Deployment
Once selected, we can run the following command to view changes and upon accepting, our application will be deployed and a live on the web:
amplify publish
Copy and paste the generated URL in your terminal to browser to view.
As you may have noticed in the CLI prompts, Amplify also supports git-based deployments. More information on setting that up can be found in the Amplify docs
Recap
Using Amplify takes care of a lot of the heavy-lifting when it comes to setting up AWS services so that we can focus on our actual business logic.
It's also good to remember that Amplify allows us to own the code that we deploy by letting us modify the generated Cloudformation templates.
Be sure to follow this series, or follow me on Twitter to get notified when the next iteration of this series comes out:
Sending emails with attachments! 📧
Until then 🤖
Top comments (7)
This is a terrific workflow process and amplify has really come far. I have been working on backend stuff with SAM and need to revisit frontend.
At the step when the lambda function needs to be updated, I am not getting the same choices reflected in the writeup, i.e. "
Environment variables configuration" using SAM CLI 1.25.0. My menu says, "1. resource access permissions, scheduled recurring invocation, lambda layers configuration" with the obvious choice being the first one. But this decision path doesn't continue your workflow. Welcome any tips. Thanks.
Hey Vince! Glad you liked the post! Environment variables were just released last week. Have you had a chance to update your CLI to the latest version?
npm i -g @aws-amplify/cli
npm i -g @aws-amplify/cli
Things move fast in the AWS universe! I think I upgraded just the previous week. That was the issue. Thanks again for the fast response and series. Kudos.
git clone git@github.com:mtliendo/amplify-email-recipes.git is returning error Cloning into 'amplify-email-recipes'...
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.
I guess it's an access to be set up by you. It's an old tutorial and could be more detailed to explain each service used and how it's used, after completing it with my project I don't have _app.js in new react next.js project, new version of it with an application in existing next.js project would be great