Add a mailform to a static website with AWS
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
- 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.
- 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.
- Go to WorkMail --> Organizations --> jodibooks --> Users --> jodiBooks B.V.
- Check if the email address you want to use is added as an alias.
IAM
-
Go to the IAM console --> policies https://us-east-1.console.aws.amazon.com/iamv2/home?region=us-east-1#/policies.
-
Create a policy
AmazonSESSendingAccess
. If it is already there, great, saves you some trouble!-
Click the JSON tab and add code below:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "ses:SendEmail", "ses:SendRawEmail" ], "Resource": "*" } ] }
-
Click "Next: ..." twice
-
Enter the name
AmazonSESSendingAccess
and descriptionSend email through SES
-
Create policy.
-
-
Create another policy with name
AWSLambdaBasicExecutionRole-sendContactForm
, descriptionAllow 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:*" ] } ] }
-
Go to Roles and create a new one.
- Select
AWS service
and then select Use caseLambda
. Click Next. - Add the policies created above. Click Next.
- Give it a name:
LambdaSendContactFormRole
and description:Allows Lambda functions to call AWS services on your behalf.
Click Create role.
- Select
Lambda
- 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.
- Enter the Function name:
sendContactForm
, select Runtime:Node.js 14.x
and under "Change default execution role selectUse an existing role
. Select the role created above. - Go to tab Configuration and click Environment variables.
- Add
RECEIVEMAIL
:info@example.com
- Add
REPLYEMAIL
:info@example.com
- Add
REGION
:eu-central-1
- Add
- Go the code tab and clear the code in
index.js
and paste the code from this repository. - Create files
confirmEmail.js
andreceiveEmail.js
(see below) - Click Deploy.
API Gateway
- 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.
- Click on the Build button for
REST API
.- The protocol should be
REST
and under Create new APINew API
should be selected. - Enter the API name
sendContactForm
and descriptionAPI to connect our homepage contact form with the sendContactForm Lambda function.
. - Choose
Edge optimized
as Endpoint Type.
- The protocol should be
- Under Resources click
Actions
-->Create Resource
.- Resource Name:
contact
, Resource Path:contact
. - Click the created OPTIONS method.
- Go to intergration request en select
Lambda Function
as Integration type and check Use Lambda Proxy integration
- Resource Name:
- Select this path and under
Actions
--> clickCreate Method
.- Add
POST
- Integration type:
Lambda Function
, check Use Lambda Proxy integration, Lambda Region:eu-central-1
, Lambda Function:sendContactForm
. - Save --> OK
- Add
- Now the most important step: Deploy the API. This needs to be done after every change.
- Under Resources click
Actions
-->Deploy API
. - Select the Deployment stage:
dev
/prod
- Under Resources click
- Cycle through steps 6 and 7 to deploy DEV/PROD versions of the API.
- Click Custom domain names in the menu on the left
- Create a new one with Domain name
contact.example.com
. SelectEdge-optimized
as Endpoint type and select the*.example.com
certificate.
- Create a new one with Domain name
- Select this domain name go to tab API mappings. Click Configure API mappings
- Create a mapping for Stage
dev
with pathdev
. - For PROD we only create one mapping: Stage
prod
with an empty path. - Save
- Create a mapping for Stage
- Now from the Configurations tab copy the API Gateway domain name; something like
<random string>.cloudfront.net
.
Route 53
- Go to Route 53 and then to the Hosted zone which the contact form is for.
- Add an entry:
- Record name:
contact
- Record type:
A
- Select Alias
- Route traffic to CloudFront distribution
- Paste the value from the Gateway API
- Click save.
- Record name:
Check if it works
Postman is a great tool for that. You can use the free version.
-
Send an
OPTIONS
request tohttps://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 tohttps://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 tohttps://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'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:
- The email that you would receive.
- 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.