DEV Community

Cover image for Serverless Contact Form Using AWS Amplify
Michael Liendo
Michael Liendo

Posted on • Updated on

Serverless Contact Form Using AWS Amplify

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 run npm 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
Enter fullscreen mode Exit fullscreen mode

Once the project has started, visit localhost:3000 and you should be presented with the following screen:

Main page of contact form site

Understanding Our Backend

architecture diagram of contact form using appsync, ses, lambda trigger, and dynamodb

Using the above image as a reference, the Amplify services we'll need for our backend are in the following order:

  1. AppSync: Fully managed GraphQL API

  2. DynamoDB: NoSQL database

  3. 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
Enter fullscreen mode Exit fullscreen mode

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:

Amplify Configure flow for static nextjs apps

Lastly, after selecting AWS profile we'll choose the profile we'd like to use.

The flow should look similar to the following screenshot:

output from going through the Amplify init process

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
Enter fullscreen mode Exit fullscreen mode

While in the CLI prompts, choose the following options:

  1. GraphQL

  2. [enter] to accept the default name

  3. API key

  4. "Contact form public API"

  5. [enter] to accept a default of 7 days

  6. [enter] to accept "No, I am done."

  7. [enter] to accept the default "N" option

  8. [enter] for a Single object with fields

  9. "y" to edit the schema now

  10. 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!
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. amplify add function

  2. Lambda function (serverless function)

  3. "contactformuploader" as the name of the function

  4. NodeJS

  5. Lambda Trigger

  6. Amazon DynamoDB Stream

  7. Use API category graphql @model backed DynamoDB table in the current Amplify project

  8. [enter] to not configure advanced settings

  9. [enter] to edit the local function now

  10. 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:

streams terminology

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

Enter fullscreen mode Exit fullscreen mode

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.

  1. Our function doesn't have permission to interact with SES

  2. We need to setup this process.env.SES_EMAIL variable

  3. We've yet to setup SES

  4. 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:

  1. We can only send/receive to verified email addresses

  2. We can only send 1 email/sec

  3. 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
Enter fullscreen mode Exit fullscreen mode

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.

AWS SES Getting Started Page

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:

  1. Click the orange "Create identity" button

  2. Select the "Email address" option and enter your desired email

  3. Click the orange "Create identity" button

Alt Text

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:

Copy ARN

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/
Enter fullscreen mode Exit fullscreen mode

Inside of this directory, you'll see the src directory for lambda, and a file titled

your-function-name-cloudformation-template.json
Enter fullscreen mode Exit fullscreen mode

Alt Text

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"
}
Enter fullscreen mode Exit fullscreen mode

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

Lambda with SES permissions

The next step is to add the environment variable to our function.

Back in the terminal, run the following command:

amplify update function
Enter fullscreen mode Exit fullscreen mode

Enter the following options:

  1. Lambda function (serverless function)

  2. [Select your function name]

  3. Environment variables configuration

  4. type SES_EMAIL

  5. Enter the email that was verified with SES

  6. I'm done

  7. 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
Enter fullscreen mode Exit fullscreen mode

This will provide a table of the primary resources we created (recall that our database is a secondary resource created by the @model directive).

amplify status

After selecting that you'd like to continue, select the following options:

  1. Yes -- generate code for the API ๐Ÿ”ฅ

  2. JavaScript

  3. [enter] to allow the default file path

  4. Yes -- generate all possible operations (recall we only allow create per our schema)

  5. [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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ 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>')
Enter fullscreen mode Exit fullscreen mode

Replace the above with this snippet:

await API.graphql({
  query: createCandidate,
  variables: {
    input: {
      name,
      email,
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

From the prompts, we'll select the following options:

  1. Hosting with Amplify Console

  2. 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
Enter fullscreen mode Exit fullscreen mode

Amplify hosting flow

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)

Collapse
 
vince_bighire_tools profile image
Vince Fulco (It / It's)

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.

Collapse
 
focusotter profile image
Michael Liendo

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?

Collapse
 
focusotter profile image
Michael Liendo

npm i -g @aws-amplify/cli

Collapse
 
focusotter profile image
Michael Liendo

npm i -g @aws-amplify/cli

Collapse
 
vince_bighire_tools profile image
Vince Fulco (It / It's)

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.

Collapse
 
emerjay profile image
Jan

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

Collapse
 
Sloan, the sloth mascot
Comment deleted