loading...
Cover image for Building a serverless contact form with AWS Lambda and AWS SES

Building a serverless contact form with AWS Lambda and AWS SES

adnanrahic profile image Adnan Rahić Updated on ・10 min read

Buy Me A Coffee

What if I told you it can be done with zero dependencies? Hope you're up for a challenge because that's exactly what we'll be doing.

This tutorial will cover the basics of both the front-end contact form, with vanilla JavaScript, and the serverless back end hosted on AWS Lambda. AWS SES is the service you use for sending the actual emails and trust me, it's so incredibly simple the configuration takes 13 seconds. Yes, I timed myself. 😁

Well, okay then. Let's jump in!

TL;DR

Just to make sure you have an overview of what we're doing today, here's a short TL;DR. You can jump to the section that interests you, and severely hurt my feelings, or just keep reading from here. Take your pick... I won't silently judge you. 😐

Note: I turned this code into an npm module for easier re-usability, and so you peeps don't need to write all the code yourself when you need a quick contact form.

What are we building?

The general idea is to build a contact form that can be added to a static website. We want to add it without managing any servers and ideally not paying anything for it to run. Here's an amazing use case for AWS Lambda.

The structure of what we want to build is rather simple. We have a simple form, with a tiny snippet of JavaScript to parse the parameters to JSON and send them to an API endpoint.

The endpoint is an AWS API Gateway event, which will trigger an AWS Lambda function. The function will tell AWS SES to send an email with the content to your email address. From there you can continue exchanging emails with the person who filled out the form. Simple, right?

overview

Let's start hacking!

Configure AWS SES

In order to send emails with the Simple Email Service AWS provides, you need to verify an email address which will be used to send the emails. It's as simple as navigating to the AWS Console and searching for Simple Email Service.

ses-1

Once there press the Email Addresses link on the left side navigation. You'll see a big blue button called Verify a New Email Address. Press it and add your email address.

ses-2

AWS will now send you a verification email to that address. Go ahead and verify it. That's pretty much it. Ready to write some code now?

Build the API with the Serverless Framework

There are a couple of main steps in building the actual API. First thing, as always, is the configuration.

1. Install the Serverless Framework

In order for serverless development to not be absolute torture, go ahead and install the Serverless framework.

$ npm i -g serverless

Note: If you’re using Linux, you may need to run the command as sudo.

Once installed globally on your machine, the commands will be available to you from wherever in the terminal. But for it to communicate with your AWS account you need to configure an IAM User. Jump over here for the explanation, then come back and run the command below, with the provided keys.

$ serverless config credentials \
    --provider aws \
    --key xxxxxxxxxxxxxx \
    --secret xxxxxxxxxxxxxx

Now your Serverless installation knows what account to connect to when you run any terminal command. Let’s jump in and see it in action.

2. Create a service

Create a new directory to house your Serverless application services. Fire up a terminal in there. Now you’re ready to create a new service.

What’s a service you ask? View it like a project. But not really. It's where you define AWS Lambda functions, the events that trigger them and any AWS infrastructure resources they require, all in a file called serverless.yml.

Back in your terminal type:

$ serverless create --template aws-nodejs --path contact-form-api

The create command will create a new service. Shocker! But here’s the fun part. We need to pick a runtime for the function. This is called the template. Passing in aws-nodejs will set the runtime to Node.js. Just what we want. The path will create a folder for the service.

3. Explore the service directory with a code editor

Open up the contact-form-api folder with your favorite code editor. There should be three files in there, but for now, we'll only focus on the serverless.yml. It contains all the configuration settings for this service. Here you specify both general configuration settings and per function settings. Your serverless.yml will be full of boilerplate code and comments. Feel free to delete it all and paste this in.

# serverless.yml

service: contact-form-api

custom:
  secrets: ${file(secrets.json)}

provider:
  name: aws
  runtime: nodejs8.10
  stage: ${self:custom.secrets.NODE_ENV}
  region: us-east-1
  environment: 
    NODE_ENV: ${self:custom.secrets.NODE_ENV}
    EMAIL: ${self:custom.secrets.EMAIL}
    DOMAIN: ${self:custom.secrets.DOMAIN}
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "ses:SendEmail"
      Resource: "*"

functions:
  send:
    handler: handler.send
    events:
      - http:
          path: email/send
          method: post
          cors: true

The functions property lists all the functions in the service. We will only need one function though, to handle the sending of emails. The handler references which function it is.

Take a look at the iamRoleStatements, they specify the Lambda has permission to trigger the Simple Email Service.

We also have a custom section at the top. This acts as a way to safely load environment variables into our service. They're later referenced by using ${self:custom.secrets.<environment_var>} where the actual values are kept in a simple file called secrets.json.

Awesome!

4. Add the secrets file

We all know pushing private keys to GitHub kills little puppies. Please don't do that. Handling this with the Serverless Framework is simple. Add a secrets.json file and paste these values in.

{
  "NODE_ENV":"dev",
  "EMAIL":"john.doe@mail.com",
  "DOMAIN":"*"
}

While testing you can keep the domain as '*', however, make sure to change this to your actual domain in production. The EMAIL field should contain the email you verified with AWS SES.

5. Write business logic

With that wrapped up, let's write the actual code. All in all, the code itself is rather simple. We're requiring the SES module, creating the email parameters and sending them with the .sendMail() method. At the bottom, we're exporting the function, making sure to make it available in the serverless.yml.

// handler.js

const aws = require('aws-sdk')
const ses = new aws.SES()
const myEmail = process.env.EMAIL
const myDomain = process.env.DOMAIN

function generateResponse (code, payload) {
  return {
    statusCode: code,
    headers: {
      'Access-Control-Allow-Origin': myDomain,
      'Access-Control-Allow-Headers': 'x-requested-with',
      'Access-Control-Allow-Credentials': true
    },
    body: JSON.stringify(payload)
  }
}

function generateError (code, err) {
  console.log(err)
  return {
    statusCode: code,
    headers: {
      'Access-Control-Allow-Origin': myDomain,
      'Access-Control-Allow-Headers': 'x-requested-with',
      'Access-Control-Allow-Credentials': true
    },
    body: JSON.stringify(err.message)
  }
}

function generateEmailParams (body) {
  const { email, name, content } = JSON.parse(body)
  console.log(email, name, content)
  if (!(email && name && content)) {
    throw new Error('Missing parameters! Make sure to add parameters \'email\', \'name\', \'content\'.')
  }

  return {
    Source: myEmail,
    Destination: { ToAddresses: [myEmail] },
    ReplyToAddresses: [email],
    Message: {
      Body: {
        Text: {
          Charset: 'UTF-8',
          Data: `Message sent from email ${email} by ${name} \nContent: ${content}`
        }
      },
      Subject: {
        Charset: 'UTF-8',
        Data: `You received a message from ${myDomain}!`
      }
    }
  }
}

module.exports.send = async (event) => {
  try {
    const emailParams = generateEmailParams(event.body)
    const data = await ses.sendEmail(emailParams).promise()
    return generateResponse(200, data)
  } catch (err) {
    return generateError(500, err)
  }
}

That's it, all about 60 lines of code, with absolutely no dependencies. Sweet!

Deploy the API to AWS Lambda

Here comes the easy part. Deploying the API is as simple as running one command.

$ serverless deploy

deploy

You can see the endpoint get logged to the console. That's where you will be sending your requests.

Test the API with Dashbird

The simplest way of testing an API is with CURL. Let's create a simple CURL command and send a JSON payload to our endpoint.

$ curl --header "Content-Type: application/json" \
  --request POST \
  --data '{"email":"john.doe@email.com","name":"John Doe","content":"Hey!"}' \
  https://{id}.execute-api.{region}.amazonaws.com/{stage}/email/send

If everything works like it should, you will get an email shortly. If not, well then you're out of luck. In cases like these, I default to using Dashbird to debug what's going on.

dashbird-lambda-mailer-2

The logs on my end are showing all green, so it's working perfectly! That's the API part done. Let's move on to the contact form itself.

Build the contact form

Because I'm not the best CSS guru in the world, I'll just entirely skip that part and show you how to make it work. 😁

Let's start with the HTML markup.

<form id="contactForm">
  <label>Name</label>
  <input type="text" placeholder="Name" name="name" required>
  <label>Email Address</label>
  <input type="email" placeholder="Email Address" name="email" required>
  <label>Message</label>
  <textarea rows="5" placeholder="Message" name="content" required></textarea>
  <div id="toast"></div>
  <button type="submit" id="submit">Send</button>
</form>

It's an incredibly simple form with three fields and a button. Let's move on to the JavaScript.

const form = document.getElementById('contactForm')
const url = 'https://{id}.execute-api.{region}.amazonaws.com/{stage}/email/send'
const toast = document.getElementById('toast')
const submit = document.getElementById('submit')

function post(url, body, callback) {
  var req = new XMLHttpRequest();
  req.open("POST", url, true);
  req.setRequestHeader("Content-Type", "application/json");
  req.addEventListener("load", function () {
    if (req.status < 400) {
      callback(null, JSON.parse(req.responseText));
    } else {
      callback(new Error("Request failed: " + req.statusText));
    }
  });
  req.send(JSON.stringify(body));
}
function success () {
  toast.innerHTML = 'Thanks for sending me a message! I\'ll get in touch with you ASAP. :)'
  submit.disabled = false
  submit.blur()
  form.name.focus()
  form.name.value = ''
  form.email.value = ''
  form.content.value = ''
}
function error (err) {
  toast.innerHTML = 'There was an error with sending your message, hold up until I fix it. Thanks for waiting.'
  submit.disabled = false
  console.log(err)
}

form.addEventListener('submit', function (e) {
  e.preventDefault()
  toast.innerHTML = 'Sending'
  submit.disabled = true

  const payload = {
    name: form.name.value,
    email: form.email.value,
    content: form.content.value
  }
  post(url, payload, function (err, res) {
    if (err) { return error(err) }
    success()
  })
})

Another 50 lines and you have the client side logic done. Feel free to drop this into your website, change the url constant to the API endpoint you deployed above. Hey presto, there's your serverless contact form done and ready for production!

Wrapping up

There you have it, a quick and easy way to add a serverless contact form to a website. Using serverless for the odd, isolated endpoint like this is great. There are absolutely no servers you need to worry about. Just deploy the code and rest assured it'll work. If something breaks, you have Dashbird watching your back, alerting you in Slack if something is wrong. Damn, I love Slack integrations.

Anyhow, I took the time to create an npm module out of the code above, so in the future, nobody needs to write this twice. Just install the package and there's your contact form endpoint up and running in less than a minute. You can find the instructions in the GitHub repo, if you want to take a look. Give it a star if you want more people to see it on GitHub.

GitHub logo adnanrahic / lambda-mailer

Simple module for receiving an email from a contact form on your website.

dependencies contributors License: MIT license

cover

Lambda Mailer

Simple module for receiving an email from a contact form on your website.

Note!

Module needs Node.js version 8 or above.

Usage

Configuration is rather simple.

1. Enable your email address in the AWS console -> Simple Email Service

2. Install the module

$ npm i lambda-mailer

3. require() it in your handler.js

// define the options for your email and domain
const options = {
  myEmail: process.env.EMAIL, // myEmail is the email address you enabled in AWS SES in the AWS Console
  myDomain: process.env.DOMAIN // add the domain of your website or '*' if you want to accept requests from any domain
}
// initialize the function
const { sendJSON, sendFormEncoded } = require('lambda-mailer')(options)
// Content-Type: application/json
// The event.body needs to be a JSON object with 3 properties

If you want to read some of my previous serverless musings head over to my profile or join my newsletter!

Or, take a look at a few of my articles right away:

Hope you guys and girls enjoyed reading this as much as I enjoyed writing it. If you liked it, slap that tiny heart so more people here on dev.to will see this tutorial. Until next time, be curious and have fun.

If you liked this tutorial, you can support my writing by buying me a coffee.

Buy Me A Coffee


Posted on by:

adnanrahic profile

Adnan Rahić

@adnanrahic

Your friendly neighborhood open-sourcerer at Sematext.com. Startup founder, author, and ex-freeCodeCamp local leader.

Discussion

markdown guide
 

Hi Adnan,

Thanks for this guide. Just recently completed my portfolio and I am creating a serverless form through this guide. That said, I am lost as to where specifically should I paste this code?

{
  "NODE_ENV":"dev",
  "EMAIL":"john.doe@mail.com",
  "DOMAIN":"*"
}

Am I suppose to do it this way?


custom:
  secrets: ${file(secrets.json)} <---- delete content and replace this

//result:
custom:
  secrets: ${
  "NODE_ENV":"dev",
  "EMAIL":"john.doe@mail.com",
  "DOMAIN":"*"}

Is this right?
This is my first time doing this. Never had experience with APIs.

 
 

actually you have to use ${self:custom.secrets:NODE_ENV} instead of ${self:custom.secrets.NODE_ENV}

(same for EMAIL, DOMAIN)

 

you dont need to replace anything,just create a file called secrets.json and paste that snippet

 

This is a nice writeup. But you may want to mention that this requires the pligin github.com/functionalone/serverles... to be installed prior to running this code. Otherwise, it fails to create the Role for the function.

 

Thanks! But it doesn't need that plugin to work. You define the IAM roles in the provider section of the serverless.yml file.

 

Interesting. It failed to create role the first time around. But on second run, it worked fine.

 

Does not work for me; Dashbird and AWS mark the send as successful but it never shows up in my inbox. I suspect as others have suggested that my email provider is intercepting it somewhere along the line but I have no idea how to remedy it on my local machine (short of setting up a less secure email?).

Edit: I went ahead and set up a mail.com account and it worked fine when sending it to that. Doesn't help much as I have to use a stricter provider in production. Tips anyone? Would be much appreciated.

 

Where is this here living? When I set it up and I test with the CURL command everything works flawlessly. But when I try to hit the submit button on the front end nothing happens?

const form = document.getElementById('contactForm')
const url = 'https://{id}.execute-api.{region}.amazonaws.com/{stage}/email/send'
const toast = document.getElementById('toast')
const submit = document.getElementById('submit')

function post(url, body, callback) {
var req = new XMLHttpRequest();
req.open("POST", url, true);
req.setRequestHeader("Content-Type", "application/json");
req.addEventListener("load", function () {
if (req.status < 400) {
callback(null, JSON.parse(req.responseText));
} else {
callback(new Error("Request failed: " + req.statusText));
}
});
req.send(JSON.stringify(body));
}
function success () {
toast.innerHTML = 'Thanks for sending me a message! I\'ll get in touch with you ASAP. :)'
submit.disabled = false
submit.blur()
form.name.focus()
form.name.value = ''
form.email.value = ''
form.content.value = ''
}
function error (err) {
toast.innerHTML = 'There was an error with sending your message, hold up until I fix it. Thanks for waiting.'
submit.disabled = false
console.log(err)
}

form.addEventListener('submit', function (e) {
e.preventDefault()
toast.innerHTML = 'Sending'
submit.disabled = true

const payload = {
name: form.name.value,
email: form.email.value,
content: form.content.value
}
post(url, payload, function (err, res) {
if (err) { return error(err) }
success()
})
})

 

I have the same problem. I don't know where to put that code...

 

Hello,

I've followed the tuto up to lambda deployment but got an error during the test.

When I go to my AWS account I can't see any API Gateway nor Lambda function.

As I realize I don't understand how all of this works, I'd like to delete/uninstall what I've done.

How can I delete everything that was created following this tuto ?

Thanks in advance.

 

I've finally managed to make it work thanks to your lambda-mailer GitHub Repo, thank you.

But now I'd like to add recaptcha or another antispam solution to my serverless contact form.

Could you help us with that ?

Thanks in advance.

 

Thanks a ton! I was able to do it successfully.

 
 

Be aware that if you set your gmail (and probably most other big providers as well like Microsoft) will not deliver your mail if it's set up like this. They will be rejected because of DMARC.

 

Works fine with MS O365 Exchange, but I think it's all about making sure SES is all set up correctly in your name servers with SPF and DKIM records which is what DMARC enforces. Before I even enabled SES I migrated name servers to Route53 and SES auto configured the necessary records.

I was shocked when I saw Route53 charges for DNS, as I think I've always just used a free service, but at the price of a can of soda per month I suppose it's worth it :-)

Cheers!