Free, safe & static email

Free, safe & static email

Going serverless improves load times, stability and security while being practically free at the same time. But without webserver to validate form entries using your domain for e-mail requires an alternative solution. In this tutorial I show how to create a React form on your static site with ReCaptcha e-mail validation using AWS services.

In this tutorial we will create a statically hosted, safe, free and spam protected contact form from scratch using React, ReCaptcha, and several AWS services. At the end you will be able to receive React form messages in your existing mail-box for free. We will go over the following:

  1. In AWS Simple Email Service (SES) verify your email and domain in AWS SES with DNS records.
  2. Create an AWS Lambda function that validates the ReCaptcha and formats the e-mail.
  3. Configure the AWS API gateway
  4. Provide a Contact form in React for visitor input.

System design

The system diagram below explains the order and role of each AWS service that is called when a visitor submits a message through the React Form. Note that, when setting up this system in this tutorial, we work our way up such that the last thing we do is write the React form.

  • A React form collects message content and a Google ReCaptcha token and sends it to the API gateway.
  • The AWS REST API Gateway receives the POST message triggers the Lambda function, if not throttled.
  • The AWS Lambda Function validates the ReCaptcha, formats the email and invokes AWS SES.
  • AWS SES verifies your send-from (domain) and send-to (email) identities.
AWS                                                    ‎
API Gateway
Validate headers & body w/ Model
Throttle w/ X-Api-Key & Usage Plan
Lambda
Verify reCAPTCHA token
Compose email
SES
Send email
React Form
name: John
email: jd@c.m
message: Hi!
POST
headers: X-Api-Key: aK3y
body: {name: John,
           email: jd@c.m,
           message: Hi!,
           recaptcha: t0k3n}
Your inbox

AWS SES

Start with claiming the email address you want you email to be forwarded to in the AWS Simple Email Service (SES) portal. Hit “Create Identity”, then Email address. You will receive an email, hit the link and your Identity (=email) should quickly show up as Verified. You will also have to verify your domain if you have not done so already, which is a bit more tricky. This is done by again hitting “Create Identity”, but this time add a domain. Verifying your domain is done by adding a few DNS records to your hosted zone. If your domain is registered with AWS Route 53, AWS can automatically configure these records. You can view these in Route 53 under hosted zones. If you host your domain name at another provider, you can just add the domain name, after which you will be forwarded to a page where you will find three DKIM CNAME records. They will look something like this and you will have to add them to your DNS records manually.

It can happen that verification does not succeed. In my experience, in contrast to the advice, DNS records update quire frequently changes. If some time has passed and AWS SES complains that the verification is not yet complete because SES can’t find the CNAME records, pay close attention to the instructions provided by your DNS provider, some want you to not include the your domain name in the CNAME record name, others do and don’t want a period in there. Their instructions are not always clear, and they won’t provide any warning.

Type Name Value
CNAME rgaerg4rgergere4ehererhehrel3siz._domainkey.example.com rgaerg4rgergere4ehererhehrel3siz.dkim.amazonses.com
CNAME ebser5g34gvse45vsa345vertvbe45ce._domainkey.example.com ebser5g34gvse45vsa345vertvbe45ce.dkim.amazonses.com
CNAME 2argarewgrvc34erverv34revslsithq._domainkey.example.com 2argarewgrvc34erverv34revslsithq.dkim.amazonses.com

Lambda

Create a Labda function. Then add a trigger. Select a source. API Gateway. New API: it a name, e.g. examplecomContactFormAPI. Security Open, since IAM is not possible and API key does not make a lot of sense (we would have to store the API key client side anyways). the righthand side click Edit. Insert the following lines that will allow your Lambda function to send emails from your verified Identity Domain

AWSLambdaBasicExecutionRole-
Copy
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor2", "Effect": "Allow", "Action": "ses:SendEmail", "Resource": "arn:aws:ses:eu-west-1:324526346638:identity/yourdomain.com" }, { "Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:aws:logs:eu-west-1:123245678531:*" }, { "Effect": "Allow", "Action": [ "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": [ "arn:aws:logs:eu-west-1:394857439857:log-group:/aws/lambda/LAMBDANAME:*" ] } ] }

Create Role

Create a role with a policy such that you Lambda function is allowed to send emails. Go to AWS IAM and in roles search for the role that your Lambda function got assigned by name.

Set up ReCaptcha

To configure reCAPTCHA in your AWS Lambda function, navigate to the AWS Lambda function overview of LAMBDANAME. Go to the configuration tab in the menu lint. Here, go to Environment variables and add a secret. The key is value pair should look something like this:

env
Copy
DOMAIN: example.com RECAPTCHAV2_SECRET_KEY: 6Lc2C24pEHSERTH345rh435hn-rgerCCerthsaq4 RECEIVER: johndoe@email.com RECIPIENT: John Doe REGION: eu-west-1 SENDER: noreply@yourdomain.com

Ensure that the RECEIVER email address is a Verified Identity in AWS SES. If the email address is not verified, your emails will not be delivered. You can verify email addresses in the SES console by following the prompts under the Email Addresses section.

AWS Lambda Code

The following AWS Lambda function is written in Python 3.12. It performs Google reCAPTCHA validation to ensure that the requests are legitimate. If the validation is successful, the function forwards the user’s email and message to a specified recipient using Amazon SES.

Python 3.12

index.py
Copy
import json import boto3 import os from datetime import datetime import urllib3 http = urllib3.PoolManager() # Get current date and time now = datetime.now() # Format the date and time timestamp = now.strftime("%A, %B %d, %Y at %H:%M") domain = os.environ['DOMAIN'] recaptcha_key = os.environ['RECAPTCHAV3_SECRET_KEY'] receiver = os.environ['RECEIVER'] recipient = os.environ['RECIPIENT'] region = os.environ['REGION'] sender = os.environ['SENDER'] client = boto3.client('ses', region_name=region) def handler(event, context): # Quit if the recaptcha verification fails recaptcha_success = verify_recaptcha(event["recaptcha"]) if not recaptcha_success: return { 'statusCode': 403 } response = client.send_email( Destination={ 'ToAddresses': ['maartenpoirot@gmail.com'] }, Message={ 'Body': { 'Text': { 'Charset': 'UTF-8', 'Data': f'\ FROM: {event["name"]} <{event["email"]}>\n\ SENT: {timestamp}\n\ TO: {recipient} <{domain}>\n\ SCORE: {recaptcha_success}\n\ ______\n\ \n\ {event["message"]}\n\ \n\ {event["recaptcha"]}\n\ ', } }, 'Subject': { 'Charset': 'UTF-8', 'Data': f'{domain} | {event["name"]}', }, }, Source=sender ) print(response) return { 'statusCode': 200, 'body': json.dumps("Email Sent Successfully. MessageId is: " + response['MessageId']) } def verify_recaptcha(token: str) -> bool | float: url = f"https://www.google.com/recaptcha/api/siteverify?secret={recaptcha_key}&response={token}" response = http.request('POST', url) if not response.status == 200: return false # Parse response data as JSON response_data = json.loads(response.data.decode('utf-8')) success = response_data.get('success', False) if not success: return False else: return response_data.get('score', None)

Test AWS Lambda Code

To ensure that your Lambda function is working correctly, you can create a new test event. Test events allow you to simulate input data for your Lambda function, helping you verify its behavior before deploying it in a production environment.

In the AWS Lambda console, navigate to your function and look for the Test tab. Here, you can create a new test event by giving it a name and configuring the following Event JSON:

Copy
{ "name": "John Doe", "email": "j.doe@email.com", "message": "Hello, This test was successful! Best, John", "recaptcha": "03AEGFAFcW-fake-1998-ReCaptcha-key-3tf3434f43AFE" }

This JSON object mimics the data structure that your function will receive from the API Gateway. By using this test event, you can check whether the Lambda function processes the input correctly, performs the ReCaptcha validation, and sends the email as expected. If there are any errors, the AWS Lambda console will provide feedback, allowing you to troubleshoot and refine your code before going live.

API Gateway

From the lambda window open the trigger we just created by clicking on its name, or go to the APIGateWay page and select it from the list of APIs.

Before we create our API gateway, we will create a data model. A data model is a JSON schema that we will use to validate the body of the request. More about data models here AWS - Understanding data models. We will be handling four fields, a name, email, message and recaptcha token field. The generated reCAPTCHA token is not a fixed size in either bytes or number of characters, so it is a tricky field to validate. From my experience, it is about 2k ± 50 characters long, so I am guessing it should never exceed 10k characters.

Copy
{ "$schema": "http://json-schema.org/draft-04/schema#", "title": "ContactFormModel", "type" : "object", "required" : [ "name", "email", "message", "recaptcha" ], "properties" : { "name" : { "type" : "string", "minLength": 1, "maxLength": 200 }, "email" : { "type" : "string", "minLength": 1, "maxLength": 320 }, "message" : { "type" : "string", "minLength": 1, "maxLength": 1000 }, "recaptcha" : { "type" : "string", "minLength": 10, "maxLength": 10000 } } }

Now let us create the API Gateway method. By default, the API created will have a root ”/” and a resource with the name of your Lambda function. This name is also included in the API endpoint that you will be using. Its function is keeping your API organized. I will refer to it as LAMBDANAME

In Resources (left panel) go to “/LAMBDANAME”. There should already be an empty OPTIONS method which we will not be using. Let us create a POST method. On the right hand side click “Create method”. The method type should be POST, the integration type “Lambda function”. Then select the lambda function from the list. If you cannot find it, make sure that you have selected the right region. Under Method request settings, hit “API key required” checkbox. This will enable throttling in a later stage. Under HTTP request headers add a header. Name it “X-Api-Key” and set it to be required. In the Request body tab add a model. Set the content type to “application/json” and select the model we just created from the drop-down box. Hit create method, and you will be sent back to the API Gateway resources screen.

Go back to “/LAMBDANAME” and on the right hand side hit “Enable CORS”. CORS stands for cross-origin resource sharing (CORS) and we need it to allow for requests form scripts running in the browser. Check the POST box and save.

Finally, in the top right of “/LAMBDANAME” hit deploy API and describe the version.

If you decide to create a new version, you will see that it shows up in the Stages tab. The invoke URL of the different stages differs and is NOT automatically updated in the corresponding AWS Lambda Trigger. So go back to the Lambda function overview screen. Here, under triggers we will now see two API Gateway triggers. One generic (/*/*/LAMBDANAME) which you can remove, and the POST method we just created (/*/POST/LAMBDANAME). Verify that the API endpoint in the trigger matches the version you created.

One way to check if the API key requirement has take effect is to go to the Stages tab and under /LAMBDANAME check fi API key for the POST method is set to Required.

Here is the Lambda Template of the function we just created:

Copy
# This AWS SAM template has been generated from your function's configuration. If # your function has one or more triggers, note that the AWS resources associated # with these triggers aren't fully specified in this template and include # placeholder values. Open this template in AWS Application Composer or your # favorite IDE and modify it to specify a serverless application with other AWS # resources. AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: An AWS Serverless Application Model template describing your function. Resources: LAMBDANAME: Type: AWS::Serverless::Function Properties: CodeUri: . Description: '' MemorySize: 128 Timeout: 3 Handler: index.handler Runtime: nodejs20.x Architectures: - x86_64 EphemeralStorage: Size: 512 EventInvokeConfig: MaximumEventAgeInSeconds: 21600 MaximumRetryAttempts: 2 PackageType: Zip Policies: - Statement: - Effect: Allow Action: - logs:CreateLogGroup Resource: arn:aws:logs:eu-west-1:597231777788:* - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: - >- arn:aws:logs:eu-west-1:597231777788:log-group:/aws/lambda/LAMBDANAME:* SnapStart: ApplyOn: None Events: Api1: Type: Api Properties: Path: /LAMBDANAME Method: POST RuntimeManagementConfig: UpdateRuntimeOn: Auto

API Gateway throttling and quota

To protect our API from abuse, it is essential to implement throttling. Throttling can be configured directly from the Stage details on the API page or by utilizing a usage plan. While basic throttling options allow for limiting requests (for example, one email per second), these settings might not suffice for our needs. Therefore, we will also implement a usage plan, which not only manages throttling but also enforces quotas—setting a fixed number of allowed requests over a defined period.

A usage plan operates using a token bucket algorithm, which helps regulate the flow of API requests. For a deeper understanding of this algorithm, please see this article on the Token Bucket Algorithm on KrakenD.

To set up a usage plan, we will create an API key and associate it with our API calls. I recommend configuring the plan with a rate limit of 2 tokens per second, allowing for a burst of 1 concurrent request and a daily quota of 50 requests. For testing purposes, it can be helpful to start with a daily limit of just 1 request, enabling you to monitor how the system handles throttling.

Once the usage plan is in place, navigate to the Usage Plan page and check the Associated API Keys tab. Here, you can view the remaining requests for the day, which is useful for verifying that your API requests are properly counted against the quota.

In our React contact form implementation, we will send the API key as an ‘X-Api-Key’ header along with the POST request. Ensure that the Usage Plan API key is securely stored in your .env.production and .env.development files to maintain its confidentiality.

contact-form.tsx
Copy
{ method: 'POST', mode: 'cors', cache: 'no-cache', body: JSON.stringify(data), headers: { 'X-Api-Key': process.env.AWS_CONTACT_FORM_API_KEY, 'Content-type': 'application/json; charset=UTF-8', }, }

Contact Form in React

This section demonstrates the implementation of a contact form in React, designed to capture user input and send it to an API endpoint. The form includes fields for the user’s name, email, and message, along with reCAPTCHA integration for added security. When the form is submitted, the data is validated, and a POST request is made to the AWS API Gateway using an API key for authentication.

Below is the complete code for the contact form:

contact-form.tsx
Copy
import {StyledForm, ErrorMessage, Placeholder} from "./contact-form.style"; import {useGoogleReCaptcha} from 'react-google-recaptcha-v3' import React, {useState, RefObject} from 'react'; import {useForm} from 'react-hook-form'; import {FaArrowRotateRight} from 'react-icons/fa6'; const AWS_GATEWAY_URL = 'https://ergagrbea8.execute-api.us-west-1.amazonaws.com/v1/yourAPIendpointhere'; const ContactForm: React.FC<{ title: RefObject<HTMLHeadingElement> }> = ({title}) => { const [mailContent, storeMailContent] = useState({ name: '', email: '', message: '' }); const [submitted, setSubmitted] = useState(false); const {executeRecaptcha} = useGoogleReCaptcha() const { register, handleSubmit, reset, formState: {errors, isSubmitting} } = useForm(); const api_key = process.env.AWS_CONTACT_FORM_API_KEY ?? ""; const handleReset = () => { setSubmitted(false) if (title && title.current) { title.current.innerHTML = "Your message,"; } }; const onSubmit = async (data: any) => { storeMailContent(data) if (!executeRecaptcha) { return } let msg = "Thank you," try { const result = await executeRecaptcha('contact') data.recaptcha = result; const response = await fetch(AWS_GATEWAY_URL, { method: 'POST', mode: 'cors', cache: 'no-cache', body: JSON.stringify(data), headers: { 'X-Api-Key': api_key, 'Content-type': 'application/json; charset=UTF-8', }, }); const responseCode = response.status if (responseCode == 200) { setSubmitted(true); reset(); } else { msg = `${responseCode} error` } } catch (error) { console.log(error) msg = `Can't send message` } if (title && title.current) { title.current.innerHTML = msg; } window.scrollTo({top: 100, behavior: "smooth"}) }; const showSubmitError = (msg: {} | string | undefined) => <p className="msg-error">{msg}</p>; const showThankYou = ( <div className="msg-confirm"> <p>Thank you for your message.<br/> I will get back to you shortly.</p> <button type="button" onClick={handleReset} className={"no-button-content"}> <FaArrowRotateRight/> </button> </div> ); return (<StyledForm onSubmit={handleSubmit(onSubmit)} method="post"> {errors && errors.submit && showSubmitError(errors.submit.message)}{submitted ? showThankYou : <> <span className={"left"}> <label htmlFor="name"> <h3>Name</h3> <input {...register("name", { required: "Your name is required", maxLength: {value: 200, message: "Your name cannot exceed 200 characters"}, })} type="text" name="name" id="name" placeholder={mailContent ? mailContent.name : "Enter your name"} disabled={isSubmitting} /> {errors.name ? <ErrorMessage>{errors.name.message}</ErrorMessage> : Placeholder} </label> <label htmlFor="email"> <h3>E-mail</h3> <input {...register("email", { required: "Your e-mail is required", maxLength: {value: 320, message: "e-mail addresses cannot exceed 320 characters"}, })} type="email" name="email" id="email" placeholder={mailContent ? mailContent.email : "your@email.address"} disabled={isSubmitting} /> {errors.email ? <ErrorMessage>{errors.email.message}</ErrorMessage> : Placeholder} </label> <span className={'legal'}> This form is protected by reCAPTCHA. Google <a href="https://policies.google.com/privacy">Privacy Policy</a> and <a href="https://policies.google.com/terms">Terms of Service</a> apply.</span> </span> <span className={"right"}> <label htmlFor="message"> <h3>Message</h3> <textarea {...register("message", { required: "A message is required", minLength: {value: 10, message: "Add a bit more..."}, maxLength: {value: 1000, message: "Messages cannot exceed 1000 characters"}, })} name="message" id="message" rows={10} placeholder={mailContent ? mailContent.message : "Dear Maarten, "} disabled={isSubmitting} /> {errors.message ? <ErrorMessage>{errors.message.message}</ErrorMessage> : Placeholder} </label> <button className={isSubmitting ? 'submitting' : ""} disabled={isSubmitting} type="submit"> </button> </span> </> }</StyledForm> ); }; export default ContactForm

Conclusion

This implementation demonstrates how to effectively set up a contact form using AWS services and Google reCAPTCHA to ensure security. If you have any questions or would like to discuss further, please don’t hesitate to reach out through the contact form. I look forward to hearing from you!