AWS Lambda Based Contact Form

Note: I'm currently not using this contact form on this site, although it worked well. I just haven't adjusted it to work with nextJS yet.

I needed a contacts form for my website and didn’t want to run a virtual server to host the processing. Saurabh Shrivastava’s article on the AWS blog site gave me all the information I required. However, a few things have changed in the AWS console and I’ve added a couple of features:-

  • constants such as receiver email address are stored in lambda parameters so they don’t have to be set in the lambda code; and
  • google captcha checking at the back-end to avoid someone getting round the captcha by writing their own form;

Background

The structure of this solution is an HTML form, with some javascript that JSON-encodes the submitted data and sends it to an AWS API Gateway, which passes the data on to a javascript (nodeJS) AWS lambda function. The Lambda function verifies the Captcha data with Google's API and if that's OK, it sends an email to the contact form recipient using AWS SES.

Components

There are three main AWS components here:-
  1. AWS Lambda - The business logic about what to do with the form data;
  2. API Gateway - An HTTP interface to Lambda; and
  3. AWS SES - The mail system used to send the mail notification
In addition, to protect against robots spamming the gateway and sending lots of unwanted mail notifications, Google Captcha is used on the front end and checked at the back end. Note that someone can always take the gateway URL from the web script and send requests directly to the API gateway, without filling in the captcha boxes. The only way to check that someone hasn't done this, is to connect to Google's API, and check that the captcha response is valid. You can rely on things happening in the browser; you should regard the browser as untrusted.
Diagram: Normal Sequence of a Successfully Submitted Form
The bit that runs in the browser consists of:-
  • An HTML form; and
  • A javascript function to bundle the form data, captch data into JSON and send
I'm completely missing out how these are served to the browser; it could be from an apache webserver or any other web hosting system.

Code

I'll start with the Lambda, HTML form and front end javascript first.

The Lambda

This is where the back end work gets done. It get's the JSON encoded form data from the user's browser, via the API Gateway.

So my lambda code is:-

'use strict';

const AWS = require('aws-sdk');
/* Use SES in same region as lambda is running */
const ses = new AWS.SES({region: process.env.AWS_REGION}); 
    
/* this is boilerplate setup when you create the function */
exports.handler = async (lamdaEvent) => {
    
    console.log('Received event:', lamdaEvent);

    // email to and from
    const RECEIVER = process.env.RECEIVER;
    const SENDER   = process.env.SENDER;
    
    // You get this from google when you settup captcha
    const googleSecret = process.env.GOOGLE_SECRET;
    
    /* The JSON from the client comes in as JSON encoded text inside lamdaEvent.body*/
    const eventbody = JSON.parse(lamdaEvent.body);
    
    /* Function to call google to check that the captcha response is valid. Better 
     * than simply relying on code running in the browser */
    const googleCaptchaProcFun = (resolve, reject) => {
        
        const queryString = require('querystring');
        const requestData = queryString.stringify( {
            secret:   googleSecret,
            response: eventbody.capt
        } );
        
        const https = require('https');
        const options = {
            hostname: 'www.google.com',
            port: 443,
            path: '/recaptcha/api/siteverify',
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Content-Length': requestData.length
            }
        };
        
        const request = https.request( options, (response ) => {
            
            response.on('data', (data) => {
               console.log( 'Verified Google Captcha' );
               
               let responseObject = JSON.parse( data );
               if (responseObject.success){
                   resolve( 'verified ' + data);
               }else{
                   reject( 'not verified ' + data )
               }
               
               
            });
        } );
        
        request.on('data', (data) => {
            console.log( 'Sending verify', data );
            
            
        });
        
        request.write( requestData );
        request.end();
        
    };
    
    const formContactName = eventbody.firstname + " " + eventbody.lastname;
    const mailParams = {
        
        Destination: {
            ToAddresses: [
                RECEIVER
            ]
        },
        Message: {
            Body: {
                Text: {
                    Data:   'name: '          + formContactName + 
                    'phone: '     + eventbody.tel + 
                    'email: '     + eventbody.email + 
                    'Website: '   + eventbody.website + 
                    'LinkedIn: '  + eventbody.linkedin + 
                    'desc: '      + eventbody.note,
                    Charset: 'UTF-8'
                }
            },
            Subject: {
                Data: 'Website Referral Form: ' + formContactName,
                Charset: 'UTF-8'
            }
        },
        Source: SENDER
    };
    const sendMailFun = (resolve, reject) => {
        
        console.log('Sending mail with name: ', formContactName);
        
        ses.sendEmail(mailParams, (err, data) => {
            
            if( err ){
                console.log('Sending error:', err);
                reject( err );
            }else{
                console.log('Email sent');
                resolve( data );
            }
        } );
    };
    
    /* As it's asynchronous, the lambda must return a promise */
    const response =
            new Promise(googleCaptchaProcFun)
            .then((result) => {

                return new Promise(sendMailFun);
            }).then((result) => {
                console.log('Promises reloved');
                return {result: "ok" };
            }).catch((err) => {
                console.log('Error', err);
                return {result: "Error"};
            });
    
    return response;
};

The HTML Form

<head>
            
    <script src="https://www.google.com/recaptcha/api.js" async defer></script>
    <script src="js/contact.js"></script>
</head>
<form id="form" method="post">

    <fieldset>
        <legend>Your details</legend>

        <div class="form-group" >
            <label for="firstname" >First Name</label>
            <input type="text"  name="firstname"    id="firstname" 
                   placeholder="First Name" class="form-control" />
        </div>

        <div class="form-group">
            <label for="lastname" >Last Name</label>
            <input type="text"  name="lastname"     id="lastname"  
                   placeholder="Last Name" class="form-control" />
        </div>
        <div class="form-group">
            <label for="tel" >Telephone</label>
            <input type="tel"   name="tel"          id="tel"       
                   placeholder="+442079460000" class="form-control" />
        </div>

        <div class="form-group">
            <label for="email" >Email</label>
            <input type="email" name="email"        id="email"     
                   placeholder="name@example" class="form-control" />
        </div>

        <div class="form-group">
            <label for="website" >Website</label>
            <input type="url" name="website"    id="website"     
                   placeholder="https://example/" class="form-control" />
        </div>

        <div class="form-group">
            <label for="linkedin" >Linkedin</label>
            <input type="url" name="linkedin"    id="linkedin"     
                   placeholder="https://uk.linkedin.com/" class="form-control" />
        </div>
    </fieldset>



    <div class="form-group">
        <label for="note" >How can I help you?</label>
        <textarea  rows="3" name="note"         id="note"      
                   placeholder="I'd like to make contact..." class="form-control" ></textarea>
    </div>


    <fieldset>
        <legend>You're not a robot, are you?</legend>
        <div class="g-recaptcha" data-sitekey="6LeoP9Q….N9N"></div>
    </fieldset>

    <button type="submit" class="btn btn-primary" >Submit</button>


    <output id="info" ></output>
</form>

The Front End Script

window.addEventListener('load', (event) => {
    
    const URL       = "https://…./contactFormMail";

    let form        = document.getElementById('form');
    let infoArea    = document.getElementById('info');

    form.addEventListener('submit', (event) => {

        event.preventDefault();

        let firstname = document.getElementById('firstname').value;
        let lastname = document.getElementById('lastname').value;
        let tel = document.getElementById('tel').value;
        let email = document.getElementById('email').value;
        let website = document.getElementById('website').value;
        let linkedin = document.getElementById('linkedin').value;
        let note = document.getElementById('note').value;

        let data = {
            'firstname': firstname,
            'lastname': lastname,
            'tel': tel,
            'email': email,
            'website': website,
            'linkedin': linkedin,
            'note': note,
            'capt': grecaptcha.getResponse()
        };

        let http = new XMLHttpRequest();

        output = (message) => {
            
            console.log( message );
            infoArea.value = message;
        };

        http.onreadystatechange = (status) => {
            if(http.readyState === XMLHttpRequest.DONE){
                
                let status = http.status;
                if (status === 0 || (status >= 200 && status < 400)) {
                                        
                    if ("ok" === JSON.parse(http.responseText).result){
                        output("Thanks. Your message has been sent");

                        form.disabled = true;
                    }else{
                        output("Sorry. There was a problem.");
                    }

                } else {
                    output("Sorry. There was a problem.");
                }
            }
        };

        http.open("POST", URL, true);
        http.setRequestHeader("Content-type", "application/json; charset=utf-8");

        http.send(JSON.stringify(data));
    });
});

The AWS Components

Create the Lambda

In the ASW Lambda console page, create a Lambda function. I've assumed that you have created a role for the Lambda to assume, that has permission to send emails through SES.

Image: Create a Lambda function

Edit in the code and parameters.

Image: Edit the Lambda function

Create the API Gateway

Image: Click "Create API" in the API Gateway Console Page
Image: HTTP API || Build

Choose add integration:- Enter a name and click “Add Integration” and select Lambda and then your lambda function.

Image: Integrate Lambda with the Gateway

Create a route for POST requests to /contactFormMail go to the Lambda integration:-

Image: Create a route, so that POST requests to /contactFormMail

Go with the default stage and click next:-

Image: Use the Default Stage

Configure Cross Origin Sharing. This tells the gateway to respond to CORS OPTIONS requests to say that certain webpages can connect to this Gateway. This doesn't verify that the connection is coming from your own script; it just allows the browser to make this check and most browsers will do this properly. The filtering against robots is the Captcha however.

Image: Configure Cors

Conclusion

It's possible to create a contact form for your website by linking API Gateway, to a Lambda function and the to SES to send you an email.
The above is basically how to do it the long way.

References

Saurabh Shrivastava’s article on theAWS blog got me to my first working solution.
Randy Findley has done something similar but build it all conveniently into a Cloudformation Stack; see:article.
Derek Woods covers the detail of setting up a domain in SES; seearticle.