Good To Know

Form Endpoint to send Emails with AWS Lambda and AWS SES

Learn how to easily build an endpoint for your forms that triggers an email at nearly zero cost and effort.

Handling form submissions is (still) a time-consuming and disappointing task. Sometimes all you want is to create a form that triggers an email with the provided details. The sad truth is, there is no such solution available for free.

But there is a solution that comes with nearly zero cost and effort: Utilizing AWS Lambda and AWS Simple Email Service (SES).

Form data to email architecture proposal

If you never worked with AWS, no worries, this requires absolutely no in-depth knowledge. All you need to know, is how to navigate AWS. However, I recommend you read about the Well-Architected Framework and to create a budget alert when you're starting to use AWS to avoid costly mistakes.

Components#

Even though it might seem trivial, I want to lose a few words to the components in use. Feel free to skip this section if you're already familiar with the AWS API Gateway, AWS Lambda, and AWS SES Services.

API Gateway#

The API Gateway is a fully managed service that allows exposing business logic via API endpoints. The API Gateway supports containerized, serverless workloads as well as web applications. It is the entry point to your application logic.

We utilize its feature to invoke Lambdas via a public endpoint in our case.

Lambda#

Lambda is based on the serverless concept. Instead of ever-running EC2 instances (the AWS solution for virtual machines), lambdas only consume resources when they are invoked. The actual costs are calculated in Gigabyteseconds (GBS). This unit is calculated by multiplying the required main memory times the execution time. For example, if you have a memory consumption of 80MB and your Lambda takes 560ms to compute, this equals ~0.044GBS

1GBS is currently billed at $0.00001667 which means your request effectively costs $0.0000007293125 or in other words, you can make about 1.37 million requests until you have to pay a single dollar.

The savings in resources are hilariously high. While virtual machines usually cost at least a few cents an hour at any given cloud provider; you can make thousands of serverless requests until you have to pay the same.

Simple Email Service (SES)#

The AWS SES is a service to send and receive emails. It supports various nice features such as DKIM. This will almost certainly ensure your mails won't get tagged as spam as long as your content does not behave like phishing.

To use SES in this scenario, you will have to confirm the email or domain you're going to use to send mails. The SES wizard is clear and concise about the instructions; you should not run into any issues here.

Why use AWS#

When it comes to architecture, in 9 of 10 cases, the definitive answer to your question is: „it depends“. So maybe let's take one step back and talk about why this solution might fit your use case.

The two factors that led to my decision were Availability and Costs. Neither is a solution with low availability nor high costs tolerable for submitting mere forms.

Availability#

The availability of this solution is at least 99,9%. Which equals about 8.7 hours of downtime per year. The AWS API Gateway and the AWS Lambda Service have an availability of at least 99,95%, according to their service level agreements (SLA).

This is enterprise level availability and most likely much higher than the availability of your self-hosted backend. Also, if necessary, it would be pretty easy to increase the availability by utilizing availability zones, for instance.

Costs#

The costs are staggeringly low. All of this easily fits in the free-tier offering of AWS and thereby your costs for the first 12 months would be $0.

After that, your costs are that low; they won't even sum up to a total of $1 per year. But let's calculate that.

Let's assume:

  • your website is frequently visited, and 50 people submit a form each day
  • the lambda takes 75MB of main memory and 80ms to complete (my average)

That would be 18250 submissions (50*365) and ~107GBS (75/1024GB*0,08s*18250 submissions) per year.

  • 18250 requests via the API Gateway are billed with $0.0219 ($1.20 per million)
  • 107 GBS via Lambda are billed at $0.00178 ($0.00001667 per GBS)
  • 18250 emails via SES are billed with $0 (the first 62 000 each month are free)

This sums up to a total of $0.02 per year. I don't think there is any other managed solution that can match these costs. But feel free to prove me wrong in the comments.

Lambda Function#

First, we create a new lambda. Navigate to Lambda on the AWS Cloud web interface. Make sure your region is set correctly (at the top bar on the right side). Click on Create function, add your desired function name and select NodeJS as runtime, and click on Create function again (at the bottom).

After the creation succeeded, you should now be in the integrated code editor with some default code:

exports.handler = async event => {
// TODO implement
const response = {
statusCode: 200,
body: JSON.stringify('Hello from Lambda!')
}
return response
}

As you can see here, a lambda function is exported by exports.handler and is an asynchronous function with a single argument event.

Now let's paste in our code to process the form submission:

const AWS = require('aws-sdk') // Import the aws sdk
const ses = new AWS.SES({ region: 'eu-central-1' }) // Create an SES client bound to eu-central-1
const RECEIVER = 'info@code-specialist.com' // The recipient
const SENDER = 'no-reply@code-specialist.com' // The sender (Must be configured with SES)
const response = {
// The response we're going to send to the requester
statusCode: 200,
body: JSON.stringify('Success')
}
exports.handler = async function (event) {
// The actual lambda
console.log('Received event:', event) // To log the event, make sure not to violate applicable data protection directives!
const message = await decodeAndTransformContent(event.body) // Decode and transform the form submission
await sendEmail(message) // Send the email
return response // Return the response
}
async function decodeAndTransformContent (eventBody) {
const buffer = Buffer.from(eventBody, 'base64') // Create a buffer
const content = buffer.toString('ascii') // Decode the base64 encoded form
const tuples = content.split('&') // "a=b&c=d" => ["a=b", "c=d"]
const kv_pairs = tuples.map(pairs => pairs.split('=')) // ["a=b", "c=d"] => [["a", "b"], ["c", "d"]]
const formatted = kv_pairs.map(pairs => `<b>${pairs[0]}</b>: ${pairs[1]}`) // [["a", "b"], ["c", "d"]] => ["<b>a</b>: c", ...]
return formatted.join('<br/>') // ["<b>a</b>: c", ...] => "<b>a</b>: b <br/>..."
}
async function sendEmail (message) {
const params = {
Destination: {
ToAddresses: [RECEIVER] // A list of recipients
},
Message: {
Body: {
Html: {
Data: message // The actual content
}
},
Subject: {
Data: 'Contact Form Submission' // Your subject
}
},
Source: SENDER // The sender of the mail (Must be configured with SES)
}
return ses.sendEmail(params).promise() // Return the sendEmail promise
}

All of this code should be self-explanatory. If not, the comments should clarify what's happening. The serverless function will take any given form event and create a HTML message body for all the key-value pairs provided.

Configure the Policy#

When you created the lambda, a basic policy has been created automatically to execute the lambda. We now have to extend this policy, so it also allows us to send emails via SES.

Navigate to the IAM service and then to Policies. You should find something named similar to AWSLambdaBasicExecutionRole-5749fdbf-a74e-49a6-a18e-e8dc2d5e7c98. Click on it and then press Edit policy. By default, this will open a visual editor. Click on the JSON tab instead.

You should now see the active policy statements in a JSON format:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:eu-central-1:1234567891011:*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:eu-central-1:1234567891011:log-group:/aws/lambda/formFunction:*"
]
}
]
}

Add another statement:

...
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
...

Finally, click on Review policy and afterward on Save changes.

Create an API Gateway#

Last but not least, we need to create an API Gateway to expose the Lambda we defined via a POST action. To do so, navigate to the API Gateway service.

Click on Create API and select HTTP API. You can now click on Add integration and select your Lambda. Next, choose your API's name and click on Next. You should now see an UI to configure your routes Select the method POST for the lambda and choose a resource path for it. The resource path will be the relative address of your endpoint. Leave everything else as it is and navigate via Next until you can click on Create.

You should now see an Invoke URL like https://code-specialistxyz.execute-api.eu-central-1.amazonaws.com, which is the base URL of your API. To call your Lambda you can now execute a post to https://code-specialistxyz.execute-api.eu-central-1.amazonaws.com/contactForm assuming your resource path is contactForm.

Testing the function#

To test your endpoint, you could use this minimal HTML5 snippet:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Test Form</title>
</head>
<body>
<!-- Make sure to use your endpoints address below -->
<form
action="https://code-specialistxyz.execute-api.eu-central-1.amazonaws.com/contactForm"
method="post"
>
<span>Name</span>
<input name="name" /><br />
<span>Email</span>
<input name="email" /><br />
<span>Message</span>
<input name="message" /><br />
<button type="submit">Submit</button>
</form>
</body>
</html>

And that's basically it. You're ready to go! 🚀

If anything didn't work as expected or you're in need of an explanation somewhere, don't hesitate to leave us a comment 😊