Forms to Emails using AWS Lambda + API Gateway + SES

When deploying static websites, I am not a fan of provisioning servers to distribute them.  There are so many alternatives that are cheaper, simpler, and faster than managing a full backend server: S3 buckets, Content-Delivery Networks (CDNs), etc.  But the catch with getting rid of a server is now you don’t have a server anymore!  Without a server, where are you going to submit forms to?  Lucky for us, in a post-cloud world, we can solve this!

In this post, I will describe how AWS Lambda and API Gateway can be used as a “serverless” backend to a fully static website that can submit forms that get sent as emails to the site owner.

Important Note

This is merely a demonstration.  For simplicity, I do not explain important things like setting up HTTPS in API Gateway but I certainly recommend it.  Also, be careful applying this solution to other contexts.  Not all data can/should be treated like publicly submittable contact form data. Most applications will require more robust solutions with authentication and data stores. Be wise, what can I say more.

Prerequisites

  • AWS Account

The project is a simple static marketing website.  Like most business websites, it has a “Contact Us” page with a form that potential customers can fill out with their details and questions.  In this situation, we want this data to be emailed to the business so they can follow-up.  This means we need an endpoint to (1) receive data from this form and (2) send an email with the form contents.

Let’s start with the form:

<form id="contact-form">
  <label for="name-input">Name:</label>
  <input type="text" id="name-input" placeholder="name here..." />

  <label for="email-input">Email:</label>
  <input type="email" id="email-input" placeholder="email here..."/>

  <label for="description-input">How can we help you?</label>
  <textarea id="description-input" rows="3" placeholder="tell us..."></textarea>

  <button type="submit">Submit</button>
</form>

And because API Gateway is annoying to use with application/x-www-form-urlencoded data, we’re just going to us jQuery to grab all the form data and submit it as JSON because it will Just Work™:

var URL = '<api-gateway-stage-url>/contact'

$('#contact-form').submit(function (event) {
  event.preventDefault()

  var data = {
    name: $('#name-input').val(),
    email: $('#email-input').val(),
    description: $('#description-input').val()
  }

  $.ajax({
    type: 'POST',
    url: URL,
    dataType: 'json',
    contentType: 'application/json',
    data: JSON.stringify(data),
    success: function () {
      // clear form and show a success message
    },
    error: function () {
      // show an error message
    }
  })
})

Handling the success and error cases are left as an exercise to the reader 🙂

Lambda Function

Now lets get to the Lambda Function! Open up the AWS Console and navigate to the Lambda page and choose “Get Started Now” or “Create Function”:

Screenshot 2016-04-05 11.36.59

On the “Select Blueprint” page, search for the “hello-world” blueprint for Node.js (not python):

Screenshot 2016-04-05 11.39.42

Now, you create your function.  Choose the “Edit Code Inline” setting which will have a big editor box with some JavaScript code in it and replace that code with the following:

var AWS = require('aws-sdk')
var ses = new AWS.SES()

var RECEIVER = '$target-email$'
var SENDER = '$sender-email$'

exports.handler = function (event, context) {
    console.log('Received event:', event)
    sendEmail(event, function (err, data) {
        context.done(err, null)
    })
}

function sendEmail (event, done) {
    var params = {
        Destination: {
            ToAddresses: [
                RECEIVER
            ]
        },
        Message: {
            Body: {
                Text: {
                    Data: 'Name: ' + event.name + '\nEmail: ' + event.email + '\nDesc: ' + event.description,
                    Charset: 'UTF-8'
                }
            },
            Subject: {
                Data: 'Website Referral Form: ' + event.name,
                Charset: 'UTF-8'
            }
        },
        Source: SENDER
    }
    ses.sendEmail(params, done)
}

Replace the placeholders for RECEIVER and SENDER with real email addresses.

Give it a name and take the defaults for all the other settings except for Role* which is where we specify an IAM Role with the permissions the function will need to operate (logs and email sending). Select that and Basic execution role which should pop-up with an IAM role dialog. Take the defaults but open the “View Policy Document” and choose “Edit”. Change the value to the following:

{
    "Version":"2012-10-17",
    "Statement":[
      {
          "Effect":"Allow",
          "Action":[
              "logs:CreateLogGroup",
              "logs:CreateLogStream",
              "logs:PutLogEvents"
          ],
          "Resource":"arn:aws:logs:*:*:*"
      },
      {
          "Effect":"Allow",
          "Action":[
              "ses:SendEmail"
          ],
          "Resource":[
              "*"
          ]
      }
    ]
}

The first statement allows you to write logs to CloudWatch. The second statement lets you use the SES SendEmail API. With the IAM Role added, we will move to setting up the API Gateway so our Lambda function will be invoked by POST’s to an endpoint.

API Gateway Setup

The process for configuring API Gateway is as follows:

  1. Create an API
  2. Create a “Contact” resource
  3. Create a “POST” method that invokes our Lambda Function
  4. Enable CORS on our resource

Open up the API Gateway in the Console:

Screenshot 2016-04-05 11.56.05

Select the “Get Started” or “Create API” button.  Give the API a useful name and continue.

Now we will create a “Resource” and some “Methods” for our API.  I will not walk you through each step of the process because the GUI is a little tricky to explain, but the process is fairly straightforward.

Using the “Actions” dropdown, “Create Resource” name it something like “Contact” or “Message”.  Then, with the resource selected, use “Actions” to “Create Method”.  Choose a POST.  Now we will connect it to our Lambda Function:

Screenshot 2016-04-05 12.05.18

Once you save this, you will need to Enable CORS so that your code in the browser can POST to this other Domain.  Choose your resource > Actions > Enable CORS.

Screenshot 2016-04-05 12.07.32

Just to be safe, I added a header to Access-Control-Allow-Headers that I believe jQuery sends on AJAX calls.  Just put this at the end of the comma-separated list: x-requested-with. I am also using the ‘*’ so that it is easy for local testing. For Production, you should add the actual domain name you will be running your website under.

Now your resources and methods should look something like this:

Screenshot 2016-04-05 12.00.34

The last step is to “Deploy API”.  It’s not too bad.  Just click through the screens and fill them out with stuff that makes sense to you.  The high-level overview is that you need to create a “Stage” and then whenever you make updates to your API, you “deploy” to a “stage”.  This means that you can deploy the same API to multiple stages and test out any changes on a “Testing” stage and if things are good, deploy to the “Production” stage.

At the end of “deploying”, they will give you an “Invoke URL”.  This URL is the root of your API.  To make requests to a resource just add the name to the end of the URL: “https://invoke-url/stage-name/resource&#8221;.   To POST to our “Contact” (or “Message”) resource and given an Invoke URL of https://1111111.magic.amazonaws.com/testing, you will make POST requests to https://1111111.magic.amazonaws.com/testing/contact.  Put this URL into the jQuery code as the value of var URL.

SES + Email Validation

We are using SES to send emails.  For testing, it restricts the email addresses that can “send” and “receive” messages to ones that have been “verified”.  It is very simple.  Just go to the SES page of the Console, choose Email Addresses > Verify New Email Address.  Do this for each email address you would like to “send as” and “send to”.

Try it Out

This should get you most of the way.  If everything worked out, you should be able to submit your contact form and then receive an email with contents.

Post questions in the comments if you hit any problems.  This is only a summary and pare down of the process I went through.

Update

Jeff Richards (http://www.jrichards.ca/) recommended an all-in-one HTML + JavaScript snippet.  Here is a Github Gist of that snippet: https://gist.github.com/tgroshon/04b94aee6331bb65f05f4e0d7ff2e8bd

Advertisements
Forms to Emails using AWS Lambda + API Gateway + SES

24 thoughts on “Forms to Emails using AWS Lambda + API Gateway + SES

  1. Mark Hayward says:

    Thanks for this, but I just cannot get it to work. It seems fine in PostMan but when I call the API Gateway function from the Ajax code I get a CORS error:
    XMLHttpRequest cannot load https://ag3mez6h5a.execute-api.us-west-2.amazonaws.com/prod. No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘http://website.com.s3-website-us-east-1.amazonaws.com’ is therefore not allowed access. The response had HTTP status code 400.

    I have CORS enabled and I added the extra header you mentioned but I just cannot get it to work. Did you have any similar issues?

    1. CORS is probably the most finicky part of the process 😉

      First idea, remember to add the name of your resource to the end of your invoke URL. e.g. if you used “message” as your resource: https://ag3mez6h5a.execute-api.us-west-2.amazonaws.com/prod/message

      Second idea, check the request headers in the Network tab of the inspector to see if any other headers are being sent that should be added to the CORS list.

      After that, probably just go through and double check all the Gateway config details. With AWS, I often miss some small config piece that just breaks things. Remember to re-deploy your API after you make changes to it. It’s a new enough service that there is not a lot of troubleshooting advice out there yet, but you can probably still find some things via google & stackexchange.

    2. Mark Hayward says:

      Thanks. There was something wrong with the JSON I was sending over. It turns out that CORS headers are only returned with a 200 so it was a misleading. The client was given a 400 error without CORS information which led to that error. Once I fixed the JSON it worked. Thanks!

  2. Jason says:

    Tommy, thanks for the tutorial. Any advice on getting this example up and running on a WordPress site? I’m able to test and confirm the API call via Postman, but having an issue getting it working from a WP form.

  3. DT says:

    Nice, but how do you prevent a malicious user from using this to send you tons of emails?

    I’d like to do something similar, and I guess I could use a captcha to make sure the user who goes to my contact form is a human, but what is to stop a user from bypassing my contact page entirely and sending a POST directly to the API Gateway?

    Ideally some secret shared between my static web site and my lambda function, but I’m not sure how that would work since my site is static….

    1. Thanks for the question! In answer to “will CORS solve this”: Yes and no. CORS will help a browser prevent cross-origin naughtiness, but it won’t stop someone running a POST directly to the API endpoint. Remember, this API endpoint is on the internet so anyone with a connection can hit it (like every API). Normally, a service would use authentication to narrow down the pool of people who can successfully submit data. My demo endpoint is unauthenticated, so it will accept data from anyone with an internet connection.

      You just need to be very judicious about the kinds of information you want to be posted unauthenticated. Do not make bank transactions this way! You can do additional validation/throttling in your Lambda Fn to catch when one email, IP, whatever is sending a ton of traffic. I also didn’t go into setting up HTTPS for an API Gateway but that is always a good idea. Beyond the unauthenticated use case, the basic security practices of running a stateful web application apply.

      For this use case of a publicly submittable contact form on a small site, it is not really a problem. We want anyone to be able to submit data. And every unauthenticated form on the internet is in the exact same situation 😉

  4. HI, I tried the same scenario for serverless architecture.
    when I tested api gateway, I am able to send request to lambda and lambda is able to send mail through ses with information provided.
    However when i go to s3 endpoint and I enter the data , after submitting the page gets refreshed. I cant see any error also on the developer console.
    I have enabled cors, static website hosting , bucket policy, iam role for using ses.
    What am I missing?
    can you help.

    1. Are you reading the form with JavaScript and then sending an Ajax request with JSON like the example? This example assumes you are using JSON, which means submitting a standard form will not work. If the you are using JavaScript to send an Ajax request but the page is reloading when the form is submitted, you may have forgotten to catch the form’s submit event and call “preventDefault()” on it.

  5. Roberto Frias says:

    Nice Post! Maybe I will add the fact of leaving the “Lambda Proxy Integration” checkbox unchecked to avoid misunderstandings. I also made wrong assumptions thinking that the order when CORS is enabled and API deployed wont matter. No better way to learn until you try it on your own. Thankx again!
    -roberto

  6. This was a great help, I was hoping to do exactly this for a static site, so I was pleased to have a nice example to follow. Like some of the other commenters I had a bit of trouble with the contact form html and js to work as expected. For me, I ended up adjusting the js to be a standalone function and then called that from an onClick event attached to the button and adjusted the button type to be type=”button” rather than submit so I wasn’t constantly losing the form data. Additionally, don’t forget to include jQuery on the page, a simple thing that got me in my cut-and-paste rushing.

    So I guess a suggestion to improve the post would be to just supply the html and js as one complete html snippet. So here’s that. No preview option on these comments, so let’s pray for good formatting.

    ============================ contact.html ===========================
    <html>
    <head>
    https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js

    function submitToAPI() {
    var URL = ‘/contact’;

    var data = {
    name: $(‘#name-input’).val(),
    email: $(‘#email-input’).val(),
    description: $(‘#description-input’).val()
    };

    $.ajax({
    type: ‘POST’,
    url: URL,
    dataType: ‘json’,
    contentType: ‘application/json’,
    data: JSON.stringify(data),
    success: function () {
    // clear form and show a success message
    alert(‘yay’);
    },
    error: function () {
    // show an error message
    alert(‘boo’);
    }
    });

    }

    </head>
    <body>
    <form id="contact-form">
    <label for="name-input">Name:</label>
    <input type="text" id="name-input" placeholder="name here…" />

    <label for="email-input">Email:</label>
    <input type="email" id="email-input" placeholder="email here…"/>

    <label for="description-input">How can we help you?</label>
    <textarea id="description-input" rows="3" placeholder="tell us…"></textarea>

    <button type="button" onClick="submitToAPI()">Submit</button>
    </form>
    </body>
    </html>

    1. heads up, if you use this snippet the script tags have been removed, so you’ll have to add them back they should be in the jquery include, and then around the submitToAPI function.

  7. Dev says:

    I am trying to send a for with an attachment, the attachment is a .doc file I am using sendRawEmail() function to achieve this… I am giving Content type as multipart/form-data, while trying in postman in gives status as 200, i am receiving the email also but all the values are coming as undefined (If I try locally it is working)…. Later i came to know that aws doesn’t support multipart/form-data. Can you please help me if I can achieve this in any other way.

  8. Abhinsv kumar Labhishetty says:

    Hi, I am building a web application in serverless architecture using AWS API Gateway+ AWS Lambda + Congnito + Service catalog
    I have implemented multiple lambda functions and created an end point on the API gateway.

    When i invoke these lambda functions from my fronted ( ajax https request) it is taking to long about ( 6147 milli seconds) to respond. Is this usual ?

    So to be specific, My lambda is basically authenticating a cognito user. I am sending the username and password from ajax request to api gateway and then invoke the lambda.

    Can you help me improve this functionality ?

  9. eHx says:

    How are you supposed to setup SES to allow all email addresses? If you use this as a contact form and expect people to send you emails, that restriction doesn’t make sense

    1. You configure SES to send the contact email from an account/domain you own like “no-reply@mydomain.com” and include the contacting person’s email in the body of the message. In this scenario, you only need to configure SES to send from one email address and to one email address (total of two).

  10. Boris Bojic says:

    Hey,

    thanks for your article – it helped me a lot.

    What I wonder is how to *secure* the AWS API – so that no one else (but my website on a S3 bucket) could use it to send me dozens of spam mails?

    Using the CORS and my site’s url is suitable via browser, sure – but using CURL or Postman, it doesn’t help at all. And spammers don’t use a browser …

    Any ideas?

    1. I am glad you found it helpful 🙂

      I have also thought a lot about this and, while there are small things you can do to make it more difficult for bad actors, the fact of the matter is this: there is little you can do to prevent bad actors from spamming publicly available services on the internet.

      Sad but true. It is the same principle that allows DDOS attacks to work: spam lots of requests to an endpoint until something breaks. Mitigating DDOS attacks is a very difficult thing to do because it goes against the fundamental architecture of the Internet.

      CSRF tokens almost work but because the page is public an anonymous attacker could automate downloading your site, scraping the CSRF token, and then making requests with that token.

      The only way to truly prevent this is to protect a service behind authentication.

      Although you cannot prevent this problem for publicly available sources, you can mitigate the outcomes on the backend.

      For instance, change the Lambda function to only store request data (fields, ip address, timestamp, etc.) in some database or queue and have a separate Lambda function read from the storage, identify suspect data, and then perform the effect (i.e. send the email).

    2. Boris Bojic says:

      Thank you … I was afraid that there was no real “soliution” for that problem or I didn’t see one. You clarified it even more 😉

Leave a Comment

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s