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, or mail server to collect e-mails, using your domain for e-mail requires an alternative solution that I will walk you through in this blog post.

Setting this up was a wild ride, but totally worth it, I guess. This post will first provide information on how to receive email on your own domain without having to pay for a mail server and without having to check a new e-mail inbox. It will go over

  1. AWS SES: Verifying your send-from (domain) and send-to (email) identities
  2. AWS Lambda Function in Python or NodeJS that accepts form content and sends the email
  3. AWS REST API Gateway that accepts form the POST from your website, validates it and which can be throttled
  4. A react form that collects message content and a Google ReCaptcha token and sends it to the API.

We will create a statically hosted, safe, free and spam protected contact form from scratch using React, and several AWS tools. Currently the writing is still a bit of a mess, because it’s quite something to get this set up.

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

Because each component is dependent on it’s previous component, we will start at the end and work our way up.

AWS SES

Start with claiming the email adress 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 verfification 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 dont 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

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 handlig 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. ReCAPTCHA tokens

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 wont 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 righthand 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.

Funally, 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.

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: testets: 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/testets:* SnapStart: ApplyOn: None Events: Api1: Type: Api Properties: Path: /testets Method: POST RuntimeManagementConfig: UpdateRuntimeOn: Auto

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.

Lambda Policy

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 seach for the role that your Lambda function got assigned by name.

Set up ReCaptcha

In 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

This RECIEVER must be a Verified Identity in AWS SES or your emails will not arrive.

AWS Lambda Code

The code below performs Google ReCaptcha validation. If the validation is successful, it will forward the email. I started out using NodeJS 16.x but support ended un June 2024 so I switched to Python 3.12, which in my opinion is easier on backwards compatibility anyways. So here is a NodeJS solution, and then Python:

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

Create a new test event with a name and set 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" }

Contact Form in React

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

API Gateway throtteling and quota

We want to throttle our API to prevent abuse. Throtteling is possible both from the Stage details on the API page as by using a usage plan. A usage plan however is also able to allow for quota, a fixed number of calls per period. For this application, the lowest throttling options (e.g. 1 email per second) would still be a nuisance, we would therefor like to (additionaly) implement a usageplan. A usage plan manages throttling and quotas using a token bucket algorithm. More about the Token Bucket algorithm at KrakenD - How API Traffic Throttling with Token Bucket algorithm works. So will need to create an API key, assign it to the API call, implement it in

Create a usage plan. I set the rate to 2 tokens per second, an the burst to 1 concurrent request and 50 requests day. For debugging it is usefull to first set the daily limit to 1. Then visit the Usage plan page, and check the Associated API keys tab. Here you will find the Requests remaining this day, which can be helpful in checking if your API requests are actually being deducted from the quotum. Configure the methods the throttle the API we are calling.

See how in the original React contact form we send the API key as ‘X-Api-Key’ header along the POST request. Make sure that the Usage Plan API key is in your .env.production and .env.development.

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', }, }