Skip to main content

How to Set Up Serverless Form Submissions with AWS Lambda

Pulblished:

Updated:

Comments: counting...

In this tutorial you will learn how to set up a form handler in AWS Lambda with and without attachments.

Introduction

We’re going to set up two Lambda functions that send out form data via an external Email server, you could also use SES instead of the external Email server if desired. The first will handle a simple application/json post request, and the second will handle multipart/form-data requests with file attachments.

Getting Started

First, we need to install serverless globally with the command npm install --global serverless, then we can create a new project with the command below, we’ll be using NodeJS for this example.

serverless create --template aws-nodejs --path form-handler-demo

After moving into the new project folder (cd form-handler-demo), you’ll want to initialize the package.json file with the command npm init -y, and then add the offline package so we can run the function locally with the command npm install --save-dev serverless-offline.

Let’s start simple and handle a basic form sent over as application/json, which is dirt simple to do with window.fetch on the front-end site, for example:

fetch(url, {
  method: 'POST',
  body: JSON.stringify(data),
  headers: {
    'Content-Type': 'application/json',
  },
  credentials: 'same-origin',
}).then(
  function (response) {
    response.status; //=> number 100–599
    response.statusText; //=> String
    response.headers; //=> Headers
    response.url; //=> String

    return response.text();
  },
  function (error) {
    error.message; //=> String
  },
);

NodeMailer

We’re going to need nodemailer so we can send Emails out.

npm install nodemailer

We’ll configure nodemailer to use the credentials we’ll provide later in environment variables.

src/mailer/transport.js

const mailer = require('nodemailer');

module.exports = mailer.createTransport({
  host: process.env.MAIL_SERVER,
  port: process.env.MAIL_PORT,
  auth: {
    user: process.env.MAIL_USER,
    pass: process.env.MAIL_PASSWORD,
  },
});

And a simple function to actually send the Email.

src/mailer/sendMail.js

const transport = require('./transport');

module.exports = async (message) => {
  const result = new Promise((resolve, reject) => {
    transport.sendMail(message, (err) => {
      if (err) {
        reject(err);
      } else {
        resolve(true);
      }
    });
  }).catch((err) => {
    console.log(err);
    return false;
  });

  return await result;
};

Optionally but recommended is Google ReCAPTCHA to fend off the bots.

npm install google-recaptcha

Next we need to validate the ReCAPTCHA (note that we don’t catch the error here, we want the function to error out if validation fails), and format the form data into a new Email and reciept.

We’ll pass the ReCAPTCHA verifier function verfier to this module from it’s parent

src/message-generator/json.js

module.exports = async ({ event, emailFrom, emailTo, verifier }) => {
  const {
    firstName,
    lastName,
    email: userEmail,
    message,
    recaptcha,
  } = JSON.parse(event.body);

  await new Promise((resolve, reject) => {
    verifier.verify({ response: recaptcha }, (err, data) => {
      const { success } = data;
      if (err || !success) {
        reject('reCAPTCHA validation failed');
      }
      resolve();
    });
  });

  const messageBody = `
First Name: ${firstName}

Last Name: ${lastName}

Email: ${userEmail}

Message:
${message}
  `;

  const receiptBody = `
Hi ${firstName},

Thanks for reaching out, I'll be in touch soon!
  `;

  const email = {
    to: emailTo,
    from: emailFrom,
    replyTo: `${firstName} ${lastName} <${userEmail}>`,
    subject: 'Contact Form',
    text: messageBody,
  };

  const receipt = {
    to: userEmail,
    from: emailFrom,
    subject: 'Contact Form',
    text: receiptBody,
  };

  return { email, receipt };
};

Side note: If you want to get fancy, you can create an HTML Email using a string template literal, for example:

src/message-generator/templates/receipt.js

// NOTE : This is stripped down to save space,
// you should search the web for an HTML Email
// boilerplate/template to get started!

const emailReceiptHTML = ({ firstName }) => `
  <!DOCTYPE html>
  <html>
    <head>
      // Add styles here!
    </head>
    <body>
      // Add styled divs here!
      Hi ${firstName},

      Thanks for reaching out, I'll be in touch soon!
    </body>
  </html>
`;

module.exports = emailRecieptHTML;

src/message-generator/json.js

const emailRecieptHTML = require('./templates/receipt');

module.exports = async ({ event, emailFrom, emailTo, verifier }) => {
  const {
    firstName,
    lastName,
    email: userEmail,
    message,
    recaptcha,
  } = JSON.parse(event.body);

  await new Promise((resolve, reject) => {
    verifier.verify({ response: recaptcha }, (err, data) => {
      const { success } = data;
      if (err || !success) {
        reject('reCAPTCHA validation failed');
      }
      resolve();
    });
  });

  const messageBody = `
First Name: ${firstName}

Last Name: ${lastName}

Email: ${userEmail}

Message:
${message}
  `;

  const receiptBody = `
Hi ${firstName},

Thanks for reaching out, I'll be in touch soon!
  `;

  const html = emailReceiptHTML({ firstName });

  const email = {
    to: emailTo,
    from: emailFrom,
    replyTo: `${firstName} ${lastName} <${userEmail}>`,
    subject: 'Contact Form',
    text: messageBody,
  };

  const receipt = {
    to: userEmail,
    from: emailFrom,
    subject: 'Contact Form',
    html,
    text: receiptBody,
  };

  return { email, receipt };
};

Moving on, we’ll write a wrapper function here so we can add more forms later, and to supply the ReCAPTCHA verifier.

src/message-generator/index.js

const googleRecaptcha = require('google-recaptcha');

const json = require('./json');

const {
  EMAIL_TO: emailTo,
  EMAIL_FROM: emailFrom,
  RECAPTCHA_SECRET,
} = process.env;

const verifier = new googleRecaptcha({ secret: RECAPTCHA_SECRET });

module.exports.json = async (event) => {
  return await json({
    event,
    emailTo,
    emailFrom,
    verifier,
  });
};

To keep things tidy, we’ll generate our responses to the function in a separate file, since they can be a bit verbose.

src/response-generator.js

module.exports.success = (event) => ({
  statusCode: 200,
  headers: {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
  },
  body: JSON.stringify(
    {
      message: 'Form submitted successfully',
      success: true,
      input: event,
    },
    null,
    2,
  ),
});

module.exports.error = (event) => ({
  statusCode: 500,
  headers: {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
  },
  body: JSON.stringify(
    {
      message: 'Server Error, please try again later',
      success: false,
      input: event,
    },
    null,
    2,
  ),
});

Entrypoint

serverless should have generated an example handler.js file, go ahead and delete that, we’ll create a new one in the src directory. This is the entrypoint for the function, and we’ll specify that later in the serverless.yml file.

If you aren’t familiar with ternary operators yet, the syntax is expression ? if true do this : else do this

src/handler.js

require('dotenv').config();
const sendmail = require('./mailer/sendmail');
const messageGenerator = require('./message-generator');
const responseGenerator = require('./response-generator');

module.exports.json = async (event) => {
  try {
    const { email, receipt } = await messageGenerator.json(event);
    const emailResult = await sendmail(email);
    const receiptResult = await sendmail(receipt);
    return !!emailResult && !!receiptResult
      ? responseGenerator.success(event)
      : responseGenerator.error(event);
  } catch (err) {
    console.log(err);
    return responseGenerator.error(event);
  }
};

We’ll need to install the dotenv package and create a .env file to test locally with our Email server credentials and ReCAPTCHA secret.

If you don’t have a ReCAPTCHA key/secret, you’ll need to generate them from Google.com/ReCAPTCHA

npm install dotenv

.env

EMAIL_TO=
EMAIL_FROM=
MAIL_SERVER=
MAIL_PORT=
MAIL_USER=
MAIL_PASSWORD=
RECAPTCHA_SECRET=

Don’t forget to add .env to your .gitignore file! I also like to add a .env.example file with the values removed so it’s clear what values are required.

Test Locally

Let’s configure serverless for local testing now.

serverless.yml

plugins:
  - serverless-offline
service: form-handler-demo
provider:
  name: aws
  runtime: nodejs12.x
  region: us-west-1
package:
  exclude:
    - .gitiginore
    - .env
    - package.json
    - package-lock.json
    - serverless.yml
functions:
  json:
    handler: src/handler.json
    events:
      - http:
          path: /json
          method: post
          cors:
            origin: '*'
            headers:
              - Content-Type

Note the region line above, I just picked the closest one to me, there are many options to choose if you log in to AWS Management Console and click the drop down menu in the upper right for regions.

Now we can use the command serverless offline to run our function locally.

Let’s create a file to test this called test-form.html.

Don’t forget to add your ReCAPTCHA site key in this file, make sure you add localhost to the allowed hosts in the ReCAPTCHA admin console as well.

This looks over-complicated for a plain HTML file, but in React I find application/json submissions to be an easy way to handle forms when you want to manually manage the data going out.

<!DOCTYPE html>
<html>
  <head>
    <script
      src="https://www.google.com/recaptcha/api.js"
      async
      defer
    ></script>
    <style>
      form {
        display: flex;
        flex-direction: column;
        width: 304px;
        margin: 2rem auto;
      }
      input {
        margin-bottom: 1rem;
      }
      button {
        margin-top: 1rem;
      }
    </style>
  </head>
  <form id="form">
    <label for="firstName">First Name</label>
    <input
      type="text"
      name="firstName"
    />
    <label for="lastName">Last Name</label>
    <input
      type="text"
      name="lastName"
    />
    <label for="email">Email Address</label>
    <input
      type="text"
      name="email"
    />
    <label for="message">message</label>
    <input
      type="text"
      name="message"
    />
    <div
      id="recaptcha"
      class="g-recaptcha"
      data-sitekey="REPLACE THIS WITH YOUR SITE KEY!"
    ></div>
    <button type="submit">Submit</button>
  </form>
  <script>
    const form = document.getElementById('form');
    form.addEventListener('submit', submitForm, true);

    function submitForm(event) {
      event.preventDefault();

      const formData = new FormData(form);
      const data = {
        firstName: formData.get('firstName'),
        lastName: formData.get('lastName'),
        email: formData.get('email'),
        message: formData.get('message'),
        recaptcha: grecaptcha.getResponse(),
      };

      fetch('http://localhost:3000/dev/json', {
        method: 'POST',
        mode: 'cors',
        body: JSON.stringify(data),
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        },
        credentials: 'same-origin',
      }).then(
        function (response) {
          console.log(response.statusText);
        },
        function (error) {
          console.error(error.message);
        },
      );
    }
  </script>
</html>

We can run npx serve test-form.html, and navigate to localhost:5000 in a browser to test the form out.

Deploy to AWS

Before we deploy, we need to add our environment variables to the serverless.yml file, and like the .env file copy it to serverless.example.yml with the credentials removed to keep them out of any git repository.

Don’t forget to add serverless.yml to your .gitignore file!

serverless.yml

plugins:
  - serverless-offline
service: form-handler-demo
provider:
  name: aws
  runtime: nodejs12.x
  region: us-west-1
  environment:
    EMAIL_TO: REPLACE_THIS_WITH_EMAIL_ADDRESS
    EMAIL_FROM: REPLACE_THIS_WITH_EMAIL_ADDRESS
    MAIL_SERVER: REPLACE_THIS_WITH_EMAIL_SERVER
    MAIL_PORT: REPLACE_THIS_WITH_EMAIL_SERVER_PORT
    MAIL_USER: REPLACE_THIS_WITH_EMAIL_API_KEY_ID
    MAIL_PASSWORD: REPLACE_THIS_WITH_EMAIL_API_KEY_SECRET
    RECAPTCHA_SECRET: REPLACE_THIS_WITH_RECAPTCHA_SECRET
package:
  exclude:
    - .gitiginore
    - .env
    - .env.example
    - package.json
    - package-lock.json
    - serverless.yml
    - serverless.example.yml
functions:
  json:
    handler: src/handler.json
    events:
      - http:
          path: /json
          method: post
          cors:
            origin: '*'
            headers:
              - Content-Type

You’ll need to log in to aws.amazon.com , head to “My Account” > “AWS Management Console”, then click on your username in the top right menu, then “Users” on the left-hand menu.

Click on the “Add user” button in the top left, I’ll call mine “serverless”, and select “Programmatic access”.

Next, click on “Create group”, which I’ll also call “serverless”, then use the search bar to find and check these policies:

  • AWSCodeDeployRoleForLambda
  • AWSCloudFormationFullAccess
  • AWSLambdaFullAccess
  • AmazonAPIGatewayAdministrator
  • IAMFullAccess

You may add tags if you wish in the next step, these are just for you to help manage multiple users.

When you get to the success page, you can now authorize serverless on your workstation to push to your AWS account with the following command.

serverless config credentials --provider aws --key YOUR_KEY_ID --secret YOUR_SECRET_KEY

Let’s add some NPM scripts to the package.json file to make things easier to remember.

"scripts": {
  "start": "serverless offline",
  "deploy:dev": "serverless deploy --stage development",
  "deploy:prod": "serverless deploy --stage production"
}

Now we can run npm run deploy:dev to push to a “development” stage on AWS, serverless should give you a URL for your function when it’s finished. You can now add that URL to the test HTML file instead of localhost to test the live version.

Accept File Attachments

New let’s send some attached files with the form, note that most Email servers, and some Email clients, impose a size limit to Emails and attachments. Anything under 10MiB should be safe for most Email systems, if you need to transfer larger files it’s time to start looking at S3 storage or the like.

We’ll start by adding an NPM package to parse the data coming in from the form.

npm install lambda-multipart-parser

Now let’s set up a new message generator, I’ve highlighted the difference between this one and the first one we did. Since we’ll send this directly without putting JavaScript in between, we need to convert the field g-recaptcha-response to recaptcha here.

src/message-generator/multipart.js

const multipartParser = require('lambda-multipart-parser');

module.exports = async ({ event, emailFrom, emailTo, verifier }) => {
  const {
    firstName,
    lastName,
    email: userEmail,
    message,
    files,
    'g-recaptcha-response': recaptcha,
  } = await multipartParser.parse(event);

  await verifier.verify({ response: recaptcha }, (err) => {
    if (err) {
      throw new Error('reCAPTCHA validation failed');
    }
  });

  const messageBody = `
First Name: ${firstName}

Last Name: ${lastName}

Email: ${userEmail}

Message:
${message}
  `;

  const receiptBody = `
Hi ${firstName},

Thanks for reaching out, I'll be in touch soon!
  `;

  const email = {
    to: emailTo,
    from: emailFrom,
    replyTo: `${firstName} ${lastName} <${userEmail}>`,
    subject: 'Contact Form',
    text: messageBody,
    attachments: files.map(
      ({ filename, content, contentType, encoding }) => {
        return {
          filename,
          content,
          contentType,
          encoding,
        };
      },
    ),
  };

  const receipt = {
    to: userEmail,
    from: emailFrom,
    subject: 'Contact Form',
    text: receiptBody,
  };

  return { email, receipt };
};

Now we export that from the index file.

src/message-generator/index.js

const googleRecaptcha = require('google-recaptcha');

const json = require('./json');
const multipart = require('./multipart');

const {
  EMAIL_TO: emailTo,
  EMAIL_FROM: emailFrom,
  RECAPTCHA_SECRET,
} = process.env;

const verifier = new googleRecaptcha({ secret: RECAPTCHA_SECRET });

module.exports.json = async (event) => {
  return await json({
    event,
    emailTo,
    emailFrom,
    verifier,
  });
};

module.exports.multipart = async (event) => {
  return await multipart({
    event,
    emailTo,
    emailFrom,
    verifier,
  });
};

And export it from the main hanlder.js file.

src/handler.js

require('dotenv').config();
const sendmail = require('./mailer/sendmail');
const messageGenerator = require('./message-generator');
const responseGenerator = require('./response-generator');

module.exports.json = async (event) => {
  try {
    const { email, receipt } = await messageGenerator.json(event);
    const emailResult = await sendmail(email);
    const receiptResult = await sendmail(receipt);
    return !!emailResult && !!receiptResult
      ? responseGenerator.success(event)
      : responseGenerator.error(event);
  } catch (err) {
    console.log(err);
    return responseGenerator.error(event);
  }
};

module.exports.multipart = async (event) => {
  try {
    const { email, receipt } = await messageGenerator.multipart(event);
    const emailResult = await sendmail(email);
    const receiptResult = await sendmail(receipt);
    return !!emailResult && !!receiptResult
      ? responseGenerator.success(event)
      : responseGenerator.error(event);
  } catch (err) {
    console.log(err);
    return responseGenerator.error(event);
  }
};

We’ll add it into the serverless.js file as a second function, we also need to specify that the API Gateway should accept multipart/form-data as a binary media type, this just allows the function to accept binary data from the form.

Serverless.yml

plugins:
  - serverless-offline
service: form-handler-demo
provider:
  name: aws
  runtime: nodejs12.x
  region: us-west-1
  environment:
    EMAIL_TO: REPLACE_THIS_WITH_EMAIL_ADDRESS
    EMAIL_FROM: REPLACE_THIS_WITH_EMAIL_ADDRESS
    MAIL_SERVER: REPLACE_THIS_WITH_EMAIL_SERVER
    MAIL_PORT: REPLACE_THIS_WITH_EMAIL_SERVER_PORT
    MAIL_USER: REPLACE_THIS_WITH_EMAIL_API_KEY_ID
    MAIL_PASSWORD: REPLACE_THIS_WITH_EMAIL_API_KEY_SECRET
    RECAPTCHA_SECRET: REPLACE_THIS_WITH_RECAPTCHA_SECRET
  apiGateway:
    binaryMediaTypes:
      - 'multipart/form-data'
package:
  exclude:
    - .env
    - .env.example
    - .gitiginore
    - package.json
    - package-lock.json
    - serverless.yml
    - serverless.example.yml
functions:
  json:
    handler: src/handler.json
    events:
      - http:
          path: /json
          method: post
          cors:
            origin: '*'
            headers:
              - Content-Type
  multipart:
    handler: src/handler.multipart
    events:
      - http:
          path: /multipart
          method: post
          cors:
            origin: '*'
            headers:
              - Content-Type

Test with Attachments

Let’s run npm start to spin up the local development instance first, you should see there are now two URL’s /dev/json, and /dev/multipart.

We’ll need to tweak the HTML page we used to test the json submission function, I’ve removed the script tag entirely and highlighted the other changes here.

<!DOCTYPE html>
<html>
  <head>
    <script
      src="https://www.google.com/recaptcha/api.js"
      async
      defer
    ></script>
    <style>
      form {
        display: flex;
        flex-direction: column;
        width: 304px;
        margin: 2rem auto;
      }
      input {
        margin-bottom: 1rem;
      }
      button {
        margin-top: 1rem;
      }
    </style>
  </head>
  <form
    id="form"
    method="post"
    action="http://localhost:3000/dev/multipart"
    enctype="multipart/form-data"
  >
    <label for="firstName">First Name</label>
    <input
      type="text"
      name="firstName"
    />
    <label for="lastName">Last Name</label>
    <input
      type="text"
      name="lastName"
    />
    <label for="email">Email Address</label>
    <input
      type="text"
      name="email"
    />
    <label for="message">message</label>
    <input
      type="text"
      name="message"
    />
    <input
      type="file"
      name="files"
      multiple
    />
    <div
      id="recaptcha"
      class="g-recaptcha"
      data-sitekey="6LfZR9wUAAAAADZEFcYfXqpymjN-hwtsb5Aj2Csd"
    ></div>
    <button type="submit">Submit</button>
  </form>
</html>

As before, you can deploy to AWS using npm run deploy:dev or npm run deploy:prod, and again just change the target URL in the test HTML file to the one that serverless spits out in the command line after deploying.

Conclusion

You have learned how to use AWS Lambda serverless functions to receive form submissions and send them out by Email, now you can add a sweet “Contact Me” form to your server-side rendered site with ease!