Add a mailform to a static website with AWS

Aug 17, 2022

The Problem

When you create a static website with for example Next.js or Gatsby.js, there is no "active" server to send emails created by your application. But we do want to offer customers (or visitors) a chance to quickly send us questions or comments. As a business you want people to be able to contact you with as little friction as possible. So we don't want to send them to their email client first. On mobile this will work pretty well, but on a desktop the experience becomes unpredictable fast.

The proposed solution

We are already hosting our static homepage at AWS. The data is uploaded to an S3 bucket and a CloudFront distribution is set up to serve the website (I wrote a tutorial on How to do static site caching in CloudFront on my wiki site). So let's see what AWS has to offer, apart from an EC2 or Lightsail instance to get this contact form working.

Google is your friend and there you will find multiple guides on how to get a contact form working with AWS Lambda and the AWS API Gateway. I have tried multiple guides, tutorials, blog posts and Stackoverflow Q&A's. Some simply didn't work, others were fine. In the end I combined what I learned from multiple solutions into our form, which I will show below.

Implementation

Configure AWS

Before we begin you need to have some information and change it in the links, code and json below:

  • Your AWS account ID
  • The region you deploy the API/Lambda in (we use eu-central-1)
  • A verified domain or email address you are going to forward the emails to
  • If you want to send a confirmation email, your SES accounts needs to be taken out of sandbox mode.

SES

  1. Check if the email domain is verified in the region (change to your region) you want to deploy this Lambda function in: https://eu-central-1.console.aws.amazon.com/ses/home?region=eu-central-1#/verified-identities. The SES region has to be the same as the Lambda region.
  2. If the domain is not already verified add it.

Workmail

We use Workmail to receive emails. So we have to add the mailform alias to our main email account.

  1. Go to WorkMail --> Organizations --> jodibooks --> Users --> jodiBooks B.V.
  2. Check if the email address you want to use is added as an alias.

IAM

  1. Go to the IAM console --> policies https://us-east-1.console.aws.amazon.com/iamv2/home?region=us-east-1#/policies.

  2. Create a policy AmazonSESSendingAccess. If it is already there, great, saves you some trouble!

    1. Click the JSON tab and add code below:

      {
          "Version": "2012-10-17",
          "Statement": [
              {
                  "Sid": "VisualEditor0",
                  "Effect": "Allow",
                  "Action": [
                      "ses:SendEmail",
                      "ses:SendRawEmail"
                  ],
                  "Resource": "*"
              }
          ]
      }
      
    2. Click "Next: ..." twice

    3. Enter the name AmazonSESSendingAccess and description Send email through SES

    4. Create policy.

  3. Create another policy with name AWSLambdaBasicExecutionRole-sendContactForm, description Allow Lambda function to create logs in CloudWatch and the JSON below (change the resource for production):

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": "logs:CreateLogGroup",
                "Resource": "arn:aws:logs:eu-central-1:<your account id>:*"
            },
            {
                "Effect": "Allow",
                "Action": [
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                ],
                "Resource": [
                    "arn:aws:logs:eu-central-1:<your account id>:log-group:/aws/lambda/sendContactForm:*"
                ]
            }
        ]
    }
    
  4. Go to Roles and create a new one.

    1. Select AWS service and then select Use case Lambda. Click Next.
    2. Add the policies created above. Click Next.
    3. Give it a name: LambdaSendContactFormRole and description: Allows Lambda functions to call AWS services on your behalf. Click Create role.

Lambda

  1. Go to the Lambda console and create a new function: https://eu-central-1.console.aws.amazon.com/lambda/home?region=eu-central-1#/create/function.
  2. Enter the Function name: sendContactForm, select Runtime: Node.js 14.x and under "Change default execution role select Use an existing role. Select the role created above.
  3. Go to tab Configuration and click Environment variables.
    1. Add RECEIVEMAIL: info@example.com
    2. Add REPLYEMAIL: info@example.com
    3. Add REGION: eu-central-1
  4. Go the code tab and clear the code in index.js and paste the code from this repository.
  5. Create files confirmEmail.js and receiveEmail.js (see below)
  6. Click Deploy.
const AWS = require('aws-sdk');

const receiveEmail = require('./receiveEmail');
const confirmEmail = require('./confirmEmail');

exports.handler = async (event) => {
    const ses = new AWS.SES({ region: process.env.REGION });
    const regex = /example.com/g; // allowed domains (CORS)
    const methods = ['OPTIONS', 'POST']; // allowed methods
    const body = JSON.parse(event.body);
    const domain = event.headers.origin; // the domain is send as header `origin`
    const response = {
        statusCode: 200,
        headers: {
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Origin': domain.match(regex) ? domain : 'https://example.com', // CORS only allows a single allowed domain in the header. To circumvent this restriction we change it depending on the source of the request. If it comes from *.example.com, it is allowed. The browser will block all other domains as they are not https://example.com.
            'Access-Control-Allow-Methods': 'OPTIONS,POST',
            Accept: 'application/json',
            'Content-Type': 'application/json'
        },
        body: undefined
    };

    // filter methods
    if (!methods.includes(event.httpMethod)) {
        console.log('Not sending: method not allowed', event);
        response.statusCode = 405;
        response.body = 'Method Not Allowed';
        return response;
    } else if (event.httpMethod === 'OPTIONS') {
        return response;
    }

    // filter domains
    if (!domain.match(regex)) {
        console.log('Not sending: domain not allowed', event);
        response.statusCode = 400;
        response.headers = {};
        response.body = JSON.stringify({
            error: 'DOMAIN_NOT_ALLOWED'
        });
        return response;
    }

    // filter no email addresses
    if (body.email === '') {
        console.log('Not sending: no email address', event);
        response.statusCode = 400;
        response.body = JSON.stringify({
            error: 'EMAIL_REQUIRED'
        });
        return response;
    }

    // filter invalid email addresses
    if (!body.email.match(/[a-z\d!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z\d!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z\d](?:[a-z\d-]*[a-z\d])?\.)+[a-z\d](?:[a-z\d-]*[a-z\d])?/)) {
        console.log('Not sending: invalid email address', event);
        response.statusCode = 400;
        response.body = JSON.stringify({
            error: 'EMAIL_INVALID'
        });
        return response;
    }

    // filter no name
    if (body.name === '') {
        console.log('Not sending: no name', event);
        response.statusCode = 400;
        response.body = JSON.stringify({
            error: 'NAME_REQUIRED'
        });
        return response;
    }

    // filter no message
    if (body.message === '') {
        console.log('Not sending: no message', event);
        response.statusCode = 400;
        response.body = JSON.stringify({
            error: 'MESSAGE_REQUIRED'
        });
        return response;
    }

    await ses.sendEmail(confirmEmail(body)).promise()
        .then(() => {
            response.body = {
                status: 'OK'
            };
        })
        .catch(err => {
            console.error(err, err.stack);
            response.statusCode = 500;
            response.body = {
                error: err.stack
            };
        });

    await ses.sendEmail(receiveEmail(body, domain)).promise()
        .then(() => {
            response.body = {
                status: 'OK'
            };
        })
        .catch(err => {
            console.error(err, err.stack);
            response.statusCode = 500;
            response.body = {
                error: err.stack
            };
        });

    response.body = JSON.stringify(response.body); // https://stackoverflow.com/a/53392346
    
    return response;
};
index.js
const ourEmailAddress = process.env.REPLYEMAIL; // ourEmailAddress is the general email address you have enabled in AWS SES for people to email you.

function confirmEmail(body) {
    const { email, name, message } = body;

    const confirmHeader = `Hi ${name},`;
    const confirmMessage = 'Thanks for sending a message to example.com. I\'ll get back to you as soon as possible.';
    const confirmFooter1 = 'Thanks,';
    const confirmFooter2 = 'Joep';

    const htmlBody = `<!DOCTYPE html>
<html>
<head>
</head>
<body>
<p>${confirmHeader}</p>
<p>${confirmMessage}</p>
<p>This is a copy of your message:</p>
<p><i>"${message}"</i></p>
<p>${confirmFooter1}<br/>${confirmFooter2}</p>
</body>
</html>`;

    const textBody = `${confirmHeader}
${confirmMessage}
This is a copy of your message:\n${message}\n
${confirmFooter1}
${confirmFooter2}`;

    const params = {
        Destination: {
            ToAddresses: [email]
        },
        Message: {
            Body: {
                Html: {
                    Charset: 'UTF-8',
                    Data: htmlBody
                },
                Text: {
                    Charset: 'UTF-8',
                    Data: textBody
                }
            },
            Subject: {
                Charset: 'UTF-8',
                Data: 'Thanks for you message on example.com!'
            }

        },
        Source: `ACME Corp <${ourEmailAddress}>`,
        ReplyToAddresses: [ourEmailAddress]
    };

    return params;
}

module.exports = confirmEmail;
confirmEmail.js
const ourEmailAddress = process.env.RECEIVEMAIL; // ourEmailAddress is the email address you enabled in AWS SES to receive the contact form emails.

function receiveEmail(body, domain) {
    const { name, email, business, message } = body;
    const params = {
        Destination: {
            ToAddresses: [ourEmailAddress]
        },
        Message: {
            Body: {
                Text: {
                    Charset: 'UTF-8',
                    Data: `${name} ${business ? `(${business}) ` : ''}(${email}) send you a message from ${domain}.
                }
            },
            Subject: {
                Charset: 'UTF-8',
                Data: `Bericht van ${name} via ${domain}`
            }
        },
        Source: ourEmailAddress,
        ReplyToAddresses: [email]
    };

    return params;
}

module.exports = receiveEmail;
receiveEmail.js

API Gateway

  1. Go to the API Gateway console and create a new API https://eu-central-1.console.aws.amazon.com/apigateway/main/precreate?region=eu-central-1.
  2. Click on the Build button for REST API.
    1. The protocol should be REST and under Create new API New API should be selected.
    2. Enter the API name sendContactForm and description API to connect our homepage contact form with the sendContactForm Lambda function..
    3. Choose Edge optimized as Endpoint Type.
  3. Under Resources click Actions --> Create Resource.
    1. Resource Name: contact, Resource Path: contact.
    2. Click the created OPTIONS method.
    3. Go to intergration request en select Lambda Function as Integration type and check Use Lambda Proxy integration
  4. Select this path and under Actions --> click Create Method.
    1. Add POST
    2. Integration type: Lambda Function, check Use Lambda Proxy integration, Lambda Region: eu-central-1, Lambda Function: sendContactForm.
    3. Save --> OK
  5. Now the most important step: Deploy the API. This needs to be done after every change.
    1. Under Resources click Actions --> Deploy API.
    2. Select the Deployment stage: dev/prod
  6. Cycle through steps 6 and 7 to deploy DEV/PROD versions of the API.
  7. Click Custom domain names in the menu on the left
    1. Create a new one with Domain name contact.example.com. Select Edge-optimized as Endpoint type and select the *.example.com certificate.
  8. Select this domain name go to tab API mappings. Click Configure API mappings
    1. Create a mapping for Stage dev with path dev.
    2. For PROD we only create one mapping: Stage prod with an empty path.
    3. Save
  9. Now from the Configurations tab copy the API Gateway domain name; something like <random string>.cloudfront.net.

Route 53

  1. Go to Route 53 and then to the Hosted zone which the contact form is for.
  2. Add an entry:
    1. Record name: contact
    2. Record type: A
    3. Select Alias
    4. Route traffic to CloudFront distribution
    5. Paste the value from the Gateway API
    6. Click save.

Check if it works

Postman is a great tool for that. You can use the free version.

  • Send an OPTIONS request to https://contact.example.com/dev/form with body:

    {
        "email":"name@example.com",
        "name":"Name",
        "message":"This message is send from Postman."
    }
    

    and header origin: http://dev.example.com.

    This should return 200 OK with an empty response body.

  • Send a POST request to https://contact.example.com/dev/form with body:

    {
        "email":"name@example.com",
        "name":"Name",
        "message":"Dit is een test bericht verstuurd vanuit Postman."
    }
    

    and header origin: http://dev.example.com.

    This should return 200 OK with response body:

        "statusCode": "200"
    }```
    
    
  • Send a POST request to https://contact.example.com/dev/form with body:

    {
        "email":"nameexample.com",
        "name":"Name",
        "message":"Dit is een test bericht verstuurd vanuit Postman."
    }
    

    and header origin: http://dev.example.com.

    This should return 400 Bad Request with body:

    {
        "error": "EMAIL_INVALID"
    }

Errors

Some known issues I had while building this form:

  • Have you deployed the API?
  • Have you deployed the Lambda function?
  • Have you created the Route 53 record?
  • Have you used the correct cloudfront address in the Route 53 record?

The React form

As we use Next.js (jodibooks.com) and Gatsby (joeplaa.com), the form is made with React (TypeScript). I made a ContactComponent that I use on my index (home) page. This is a simplified version of that. Hopefully I didn't cut away too much, otherwise you can contact me here ;-)

import React, { ReactElement, useState } from 'react';
import { Button, Col, Row, Form, FormFeedback, FormGroup, Label, Input, Spinner } from 'reactstrap';

interface FormState {
    name: string,
    business: string,
    email: string,
    message: string,
    status: undefined | 'Sending' | 'OK' | 'Error' | 'NAME_REQUIRED' | 'EMAIL_REQUIRED' | 'MESSAGE_REQUIRED' | 'EMAIL_INVALID'
}

interface ErrorState {
    nameError: boolean,
    emailError: boolean
    messageError: boolean
}

const initialFormState: FormState = {
    name: '',
    business: '',
    email: '',
    message: '',
    status: undefined
};

const initialErrorState: ErrorState = {
    nameError: false,
    emailError: false,
    messageError: false
};

function validateEmail(email: string): boolean {
    const regExEmail = /[a-z\d!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z\d!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z\d](?:[a-z\d-]*[a-z\d])?\.)+[a-z\d](?:[a-z\d-]*[a-z\d])?/;
    return regExEmail.test(String(email).toLowerCase());
}

const checkNameError = (name: string): boolean => {
    if (name === '') {
        return true;
    } else {
        return false;
    }
};

const checkEmailError = (email: string): boolean => {
    if (email === '' || !validateEmail(email)) {
        return true;
    } else {
        return false;
    }
};

export default function ContactComponent(): ReactElement {
    // local state
    const [formState, setFormState] = useState<FormState>(initialFormState);
    const [errorState, setErrorState] = useState<ErrorState>(initialErrorState);

    // reset form after sending email
    function resetForm(): void {
        setFormState(initialFormState);
        setErrorState(initialErrorState);
    }

    // Set error state when form is checked
    function checkError(): boolean {
        const nameError = checkNameError(formState.name);
        const mailError = checkEmailError(formState.email);
        const messageError = checkNameError(formState.message);

        if (nameError || mailError || messageError) {
            setErrorState(s => ({
                ...s,
                name: nameError,
                email: mailError,
                message: messageError
            }));
        }

        return nameError || mailError || messageError;
    }

    function onBlur(input: 'name' | 'email' | 'message'): void {
        switch (input) {
            case 'name':
                setErrorState(s => ({ ...s, name: checkNameError(formState.name) }));
                break;
            case 'email':
                setErrorState(s => ({ ...s, email: checkEmailError(formState.email) }));
                break;
            case 'message':
                setErrorState(s => ({ ...s, message: checkNameError(formState.message) }));
                break;
        }
    }

    function handleSubmit(): void {
        if (!checkError()) {
            setFormState({ ...formState, status: 'Sending' });

            const body = JSON.stringify(formState);

            const requestOptions = {
                method: 'POST',
                body,
                headers: {
                    Accept: 'application/json',
                    'Content-Type': 'application/json'
                }
            };

            fetch(`${process.env.GATSBY_MAIL_URL}/form`, requestOptions)
                .then((response) => {
                    if (response.ok) {
                        resetForm();
                    }
                    return response.json();
                })
                .then((data) => {
                    setFormState({ ...formState, status: data.status || data.error || 'Error' });
                })
                .catch(() => { // unknown errors
                    setFormState({ ...formState, status: 'Error' });
                });
        }
    }

    function formComponent(): ReactElement {
        let header = 'Or send me an email';
        let body = <Form>
            <FormGroup>
                <Label for="name" className='label-bold'>Name</Label>
                <Input type="text" name="name" id="name" placeholder="John Doe" value={formState.name} onChange={(e): void => { e.preventDefault(); setFormState({ ...formState, name: e.target.value }); }} onBlur={(): void => onBlur('name')} invalid={errorState.nameError} />
                <FormFeedback>Please enter a name</FormFeedback>
            </FormGroup>
            <FormGroup>
                <Label for="business-name" className='label-bold'>Business name</Label>
                <Input type="text" name="business-name" id="business-name" placeholder="ACME" value={formState.business} onChange={(e): void => { e.preventDefault(); setFormState({ ...formState, business: e.target.value }); }} />
            </FormGroup>
            <FormGroup>
                <Label for="email" className='label-bold'>Email</Label>
                <Input type="email" name="email" id="email" placeholder="name@email.com" value={formState.email} onChange={(e): void => { e.preventDefault(); setFormState({ ...formState, email: e.target.value }); }} onBlur={(): void => onBlur('email')} invalid={errorState.emailError} />
                <FormFeedback>Enter a valid email address</FormFeedback>
            </FormGroup>
            <FormGroup>
                <Label for="other" className='label-bold'>Questions / other</Label>
                <Input type="textarea" name="text" id="other" placeholder="I like your website and I want to know more about..." style={{ height: '120px' }} value={formState.message} onChange={(e): void => { e.preventDefault(); setFormState({ ...formState, message: e.target.value }); }} onBlur={(): void => onBlur('message')} invalid={errorState.messageError} />
            </FormGroup>
        </Form>;
        let buttons = <>
            <Button color="primary" onClick={(): void => handleSubmit()}>Submit</Button>{' '}
            <Button color="secondary" onClick={resetForm}>Clear</Button>
        </>;

        switch (formState.status) {
            case 'Sending':
                header = 'Sending';
                body = <Row>
                    <Col>
                        Sending the email...
                    </Col>
                </Row>;
                buttons = <>
                    <Button disabled><Spinner size='sm' /></Button>{' '}
                    <Button color="secondary" disabled>Clear</Button>
                </>;
                break;
            case 'OK':
                header = 'Thank you for your inquiry';
                body = <Row>
                    <Col>
                        Thank you for sending this message. I&apos;ll get back to you as soon as I can.
                    </Col>
                </Row>;
                buttons = <>
                    <Button color="secondary" onClick={resetForm}>Reset</Button>
                </>;
                break;
            case 'EMAIL_REQUIRED':
            case 'EMAIL_INVALID':
            case 'NAME_REQUIRED':
            case 'MESSAGE_REQUIRED':
            case 'Error':
                header = 'Error!';
                body = <Row>
                    <Col>
                        Thank you for your interest. Something went wrong sending your message. Please try again or use another channel to contact me.
                    </Col>
                </Row>;
                buttons = <>
                    <Button color="primary" onClick={(): void => setFormState({ ...formState, status: undefined })}>TryAgain</Button>{' '}
                    <Button color="secondary" onClick={resetForm}>Clear</Button>
                </>;
                break;
            default:
                break;
        }

        return (
            <>
                <h2>{header}</h2>
                <div>{body}</div>
                <div>{buttons}</div>
            </>
        );
    }

    return (
        <section>
            {formComponent()}
        </section>
    );
}

Run the dev environment and try to send an email to yourself. You should get two emails:

  1. The email that you would receive.
  2. The email that your customer would receive.

Customizing

Now it's time to customize:

  • Make the form fit into your website design.
  • Prettify the confirmation email. We added a nice footer and logo.
  • For joeplaa I added a list with checkboxes.
  • Or add additional fields.