Nov 26, 2022
5 mins read
Lambda Function URLs were introduced several years ago. Previously, API Gateway had to be deployed if you wanted to invoke a lambda function through HTML. This is still the preferred solution for complex applications with a large API. If all you need is a simple backend to process a form sent from your website, then Lambda Function URL is a better solution.
In this post we are creating a solution for processing contact forms with Lambda Function URLs. It sounds simple, but there are a few gotchas that you need to be aware of.
The full deployment-ready solution is available on GitHub.
Assume that we have a website with a form that we need to process in some way. In our case this is a typical contact form containing the name of the sender, their email and a text message. When the user submits the form a POST request is sent to our Lambda function. This is achieved by the following HTML code:
<form
action="https://13apwmrj3uhrdpmo4chw7fpcnq0vmqef.lambda-url.ap-southeast-2.on.aws/"
method="post"
></form>
where https://13apwmrj3uhrdpmo4chw7fpcnq0vmqef.lambda-url.ap-southeast-2.on.aws/
is the URL of the Lambda function.
Typically, the information provided by the user is included in an email that the Lambda function sends to the website owner or administrator. We will be using AWS Simple Email Service (SES). You will have to verify your email to be able to send emails through SES.
After the form has been submitted and the email sent, the user would be redirected to a page on the website that acknowledges the receipt of the message. If an error occurs the Lambda function redirects to a corresponding error page. Nothing new so far, all standard behaviour.
If you used Lambda functions through API Gateway you would expect the form data to appear as a query string passed to the function in the event
parameter, something like this:
{
"queryStringParameters": {
"sender": "John",
"email": "john@example.com",
"message": "Nice website!"
}
}
This is not the case with Lambda function URLs, and I was unable to find any documentation regarding this. It turns out the form data is escaped first and then encoded into the body
of the event
using Base64. If you look into the event object you will find:
{
"queryStringParameters": {},
"body": "c2VuZGVyPUpvaG4mZW1haWw9am9obiU0MGV4YW1wbGUuY29tJm1lc3NhZ2U9TmljZSt3ZWJzaXRlIQ==",
"isBase64Encoded": true
}
In Python, the base64
package from the standard library can be used to decode the string:
import base64
s = "c2VuZGVyPUpvaG4mZW1haWw9am9obiU0MGV4YW1wbGUuY29tJm1lc3NhZ2U9TmljZSt3ZWJzaXRlIQ=="
decoded = base64.b64decode(s.encode()).decode("UTF-8")
base64.b64decode
takes a byte string as an input and produces the byte string as an output. To convert from and to str
we used encode
and decode
, respectively. The decoded message is
sender=John&email=john%40example.com&message=Nice+website!
As we see, the string is escaped and must be further decoded. We could have used codecs.escape_decode
, but there are some additional problems with parsing UTF-8 encoded characters. And this is already a lot of code for such a mundane task as parsing form data.
Luckily, urllib.parse
has the required functionality:
from urllib.parse import urlparse, parse_qs
p = urlparse("?" + decoded)
params = parse_qs(p.query)
The params
variable is a dictionary with the following content:
{
"sender": ["John"],
"email": ["john@example.com"],
"message": ["Nice website!"]
}
Note, that urllib.parse.urlparse
requires a valid URL string, thus “?” has to be prepended.
We are now ready to implement the rest of the Lambda function. Following the best practices we will pass sensitive information like the destination email and the redirect URLs as environment variables. We also moved the boto3 SES
client initialisation outside the handler function to avoid re-initialising it every time the function is invoked. (In our case it does not matter, of course, since we are not expecting several form submissions every second.)
And here is the function:
import boto3
import os
import base64
from urllib.parse import urlparse, parse_qs
error = None
try:
ses = boto3.client("ses")
HOST_URL = os.environ["HostURL"]
REDIRECT_PAGE = os.environ["RedirectPage"]
ERROR_PAGE = os.environ["ErrorPage"]
SENDER_EMAIL = os.environ["SenderEmail"]
RECIPIENT_EMAIL = os.environ["RecipientEmail"]
except Exception as e:
print(f"Error initialising Lambda Function\n{e}")
error = e
CHARSET = "UTF-8"
def handler(event, context):
if error is not None:
return {
"statusCode": 301,
"headers": {
"Location": f"{HOST_URL}/{ERROR_PAGE}"
},
}
if "body" in event and len(event["body"]) > 0:
decoded = base64.b64decode(event["body"]).decode(CHARSET)
parsed = urlparse("?" + decoded)
params = parse_qs(parsed.query)
if "sender" in params and "email" in params and "message" in params:
sender = params["sender"][0]
email = params["email"][0]
text = params["message"][0]
content = f"{sender} sent the following message:\n" + text
message = {
"Subject": {
"Data": f"{sender} sent a message from {HOST_URL}",
"Charset": CHARSET
},
"Body": {
"Text": {
"Data": content,
"Charset": CHARSET
}
}
}
try:
response = ses.send_email(Source=SENDER_EMAIL,
Destination=destination,
Message=message,
ReplyToAddresses=[f'"{sender}"<{email}>'],
ReturnPath=SENDER_EMAIL)
except Exception as e:
print(f"SES Exception\nparameters: {params}\n{e}")
return {
"statusCode": 301,
"headers": {
"Location": f"{HOST_URL}/{ERROR_PAGE}"
}
}
return {
"statusCode": 301,
"headers": {
"Location": f"{HOST_URL}/{REDIRECT_PAGE}"
}
}
print(f"wrong event format:\n{event}")
return {
"statusCode": 301,
"headers": {
"Location": f"{HOST_URL}/{ERROR_PAGE}"
}
}
It is a good idea define CORS to restrict the access to the URL to your host only. This could potentially reduce the amount of spam in the receiver mailbox. You will still get junk messages sent by bots. There are several measures you can implement on the front-end side, but this is a subject for another post.