Creating a contact form with AWS Lambda and .Net Core

By Alex Hyett on in Developers

I recently switched my website over from a standard Wordpress installation to a static site built with Gatsby.js and React. With Gatsby.js you get a blazingly fast website but you do miss out on some of the features that come as standard with Wordpress such as comments and forms.

I have been using Disqus for my commenting system and Formspree.io for a simple way to send an email when someone fills out my contact form.

In this post, I will show you how I replaced my Formspree.io contact form with an AWS Lambda function.

Getting Started

As the title of this post suggests I am going to be using C# .Net Core for my Lambda function. To get started with Amazon Lambda and .Net Core you will want to install the Amazon Lambda Tools and Templates. This will also give you access to the dotnet lambda command which we will be using later as well as a number of starter templates

dotnet tool install -g Amazon.Lambda.Tools
dotnet new -i Amazon.Lambda.Templates

After installing this you can view the available templates by typing in dotnet new -all. You should get a list that looks something like this:

Usage: new [options]

Options:
  -h, --help          Displays help for this command.
  -l, --list          Lists templates containing the specified name. If no name is specified, lists all templates.
  -n, --name          The name for the output being created. If no name is specified, the name of the current directory is used.
  -o, --output        Location to place the generated output.
  -i, --install       Installs a source or a template pack.
  -u, --uninstall     Uninstalls a source or a template pack.
  --nuget-source      Specifies a NuGet source to use during install.
  --type              Filters templates based on available types. Predefined values are "project", "item" or "other".
  --dry-run           Displays a summary of what would happen if the given command line were run if it would result in a template creation.
  --force             Forces content to be generated even if it would change existing files.
  -lang, --language   Filters templates based on language and specifies the language of the template to create.


Templates                                                 Short Name                              Language          Tags
---------------------------------------------------------------------------------------------------------------------------------------------------------
Order Flowers Chatbot Tutorial                            lambda.OrderFlowersChatbot              [C#]              AWS/Lambda/Function
Lambda Detect Image Labels                                lambda.DetectImageLabels                [C#], F#          AWS/Lambda/Function
Lambda Empty Function                                     lambda.EmptyFunction                    [C#], F#          AWS/Lambda/Function
Lex Book Trip Sample                                      lambda.LexBookTripSample                [C#]              AWS/Lambda/Function
Lambda Simple DynamoDB Function                           lambda.DynamoDB                         [C#], F#          AWS/Lambda/Function
Lambda Simple Kinesis Firehose Function                   lambda.KinesisFirehose                  [C#]              AWS/Lambda/Function
Lambda Simple Kinesis Function                            lambda.Kinesis                          [C#], F#          AWS/Lambda/Function
Lambda Simple S3 Function                                 lambda.S3                               [C#], F#          AWS/Lambda/Function
Lambda Simple SQS Function                                lambda.SQS                              [C#]              AWS/Lambda/Function
Lambda ASP.NET Core Web API                               serverless.AspNetCoreWebAPI             [C#], F#          AWS/Lambda/Serverless
Lambda ASP.NET Core Web Application with Razor Pages      serverless.AspNetCoreWebApp             [C#]              AWS/Lambda/Serverless
Serverless Detect Image Labels                            serverless.DetectImageLabels            [C#], F#          AWS/Lambda/Serverless
Lambda DynamoDB Blog API                                  serverless.DynamoDBBlogAPI              [C#]              AWS/Lambda/Serverless
Lambda Empty Serverless                                   serverless.EmptyServerless              [C#], F#          AWS/Lambda/Serverless
Lambda Giraffe Web App                                    serverless.Giraffe                      F#                AWS/Lambda/Serverless
Serverless Simple S3 Function                             serverless.S3                           [C#], F#          AWS/Lambda/Serverless
Step Functions Hello World                                serverless.StepFunctionsHelloWorld      [C#], F#          AWS/Lambda/Serverless
Console Application                                       console                                 [C#], F#, VB      Common/Console
Class library                                             classlib                                [C#], F#, VB      Common/Library
Unit Test Project                                         mstest                                  [C#], F#, VB      Test/MSTest
NUnit 3 Test Project                                      nunit                                   [C#], F#, VB      Test/NUnit
NUnit 3 Test Item                                         nunit-test                              [C#], F#, VB      Test/NUnit
xUnit Test Project                                        xunit                                   [C#], F#, VB      Test/xUnit
Razor Page                                                page                                    [C#]              Web/ASP.NET
MVC ViewImports                                           viewimports                             [C#]              Web/ASP.NET
MVC ViewStart                                             viewstart                               [C#]              Web/ASP.NET
ASP.NET Core Empty                                        web                                     [C#], F#          Web/Empty
ASP.NET Core Web App (Model-View-Controller)              mvc                                     [C#], F#          Web/MVC
ASP.NET Core Web App                                      webapp                                  [C#]              Web/MVC/Razor Pages
ASP.NET Core with Angular                                 angular                                 [C#]              Web/MVC/SPA
ASP.NET Core with React.js                                react                                   [C#]              Web/MVC/SPA
ASP.NET Core with React.js and Redux                      reactredux                              [C#]              Web/MVC/SPA
Razor Class Library                                       razorclasslib                           [C#]              Web/Razor/Library/Razor Class Library
ASP.NET Core Web API                                      webapi                                  [C#], F#          Web/WebAPI
global.json file                                          globaljson                                                Config
NuGet Config                                              nugetconfig                                               Config
Web Config                                                webconfig                                                 Config
Solution File                                             sln                                                       Solution

Examples:
    dotnet new mvc --auth Individual
    dotnet new lambda.SQS
    dotnet new --help

When I first started I used the empty function template dotnet new lambda.EmptyFunction for creating new lambda functions.

If you want a boilerplate template already set up with dependency injection, Serilog and config file then you can check out my project on GitHub.

If you just want a working contact form then you can check out lambda-contact-form.

Setting up the Lambda function

To get started we are going to set up a simple Lambda function from my lamda-dotnet-console project.

This function takes a string and capitalises it. If you are using my lamda-dotnet-console project you will see that I have added a prefix, read from appsettings.json to make sure the config is being read properly.

Lambda functions are set up as library projects which means to test it you are going to have either create another console application that uses the library or use the unit tests that come with it.

To get started we are just going to deploy what we have.

Deploying to AWS

I am going to assume you have used AWS before and already have a credential profile set up on your computer.

Deployment is simple with the Lambda tools we installed.

dotnet lambda deploy-function lambda-dotnet-console

The argument after deploy-function is the name of your function. You can call it whatever you want but you will need to update what you have written in the defaults file aws-lambda-tools-defaults.json.

During deployment, it will ask you to set up IAM Credentials or pick one you may have created before.

Once it is deployed you can test it using the following:

dotnet lambda invoke-function lambda-dotnet-console --payload "Hello World"

If you are using my GitHub project you should see something like this:

Amazon Lambda Tools for .NET Core applications (3.1.1)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Payload:
"TestHELLO WORLD"

Log Tail:
START RequestId: b40718bb-fec0-11e8-b682-07112db8d970 Version: $LATEST
END RequestId: b40718bb-fec0-11e8-b682-07112db8d970
REPORT RequestId: b40718bb-fec0-11e8-b682-07112db8d970    Duration: 3523.77 ms    Billed Duration: 3600 ms     Memory Size: 256 MB    Max Memory Used: 67 MB

Don’t worry too much about the Billed Duration as you get 1 Million Requests and 400,000 GB-Seconds per month for free.

Setting environment variables

To configure our function we are going to use environment variables in the same way we do for Docker containers. Using environment variables we can override the settings in appsettings.json.

We can do this because we have the NuGet package Microsoft.Extensions.Configuration.EnvironmentVariables installed.

This is then set up in our function like this:

var configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", optional : false, reloadOnChange : true)
    .AddEnvironmentVariables(prefix: "LAMBDA_")
    .Build();

_appSettings = new AppSettings();
configuration.GetSection("App").Bind(_appSettings);

You can override the prefix using the following environment variable: LAMBDA_App__Prefix (Note the double underscore). The project is set up to use Serilog, I recommend Seq if you want a nice way to view logs. You can follow my tutorial on how to set this up on AWS.

You will need to log on to the AWS Console if you want to change the environment variables. There probably is a way to do this on the command line but for the odd change, it is just easier to do it using the interface.

Once you have logged on to AWS, use the Services search to find Lambda. Then click on your Lambda function and scroll down to the environment variable section.

Lambda Environment Variables

We are going to update the prefix just to make sure it works. Remember to click save and then we can invoke our function again in the same way as before. Your output should look like this:

Amazon Lambda Tools for .NET Core applications (3.1.1)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Payload:
"LambdaHELLO WORLD"

Log Tail:
START RequestId: 9b30342f-fec5-11e8-b090-d5e24f128b73 Version: $LATEST
END RequestId: 9b30342f-fec5-11e8-b090-d5e24f128b73
REPORT RequestId: 9b30342f-fec5-11e8-b090-d5e24f128b73    Duration: 3520.72 ms    Billed Duration: 3600 ms     Memory Size: 256 MB    Max Memory Used: 67 MB

If you set it up correctly you should see LambdaHELLO WORLD instead of TestHELLO WORLD.

Creating a contact form endpoint

My goal for the contact form endpoint was to be able to replace the action URL on my contact form with my lambda function so I didn’t need to use FormSpree anymore.

Requirements

These are the requirements for my new endpoint.

  • Should accept the fields Name, Email, Phone, Website and Message.
  • Should get fields from a form submit e.g. a content type of application/x-www-form-urlencoded.
  • Should send an email to my chosen address.
  • Should redirect to a thank you page on submission.
  • Should log messages and errors to Seq.

Sending an Email in .Net Core

I am using MailKit and MimeKit NuGet packages for sending the email. This is the code I am using:

public async Task SendEmail(string body)
{
    var message = new MimeMessage();
    message.From.Add(new MailboxAddress("Contact Form", _settings.EmailFrom));
    message.To.Add(new MailboxAddress(_settings.EmailTo));
    message.Subject = "Contact Request";
    message.Body = new TextPart(TextFormat.Text)
    {
        Text = body
    };

    using(var client = new SmtpClient())
    {
        client.Connect(_settings.Host, _settings.Port, _settings.Port == 465);
        client.AuthenticationMechanisms.Remove("XOAUTH2");
        await client.AuthenticateAsync(_settings.Username, _settings.Password);
        await client.SendAsync(message);
        await client.DisconnectAsync(true);
    }
}

I have put all the settings for the email in appsettings.json so they can be overridden using environment variables. Again you can see a working example on GitHub.

I also updated my FunctionHandler to take a ContactRequest object and return a JSON object with the location of the thank you page. By default, the function will just convert your object to a string so we need to add an additional JSON serializer to get the right output. Your function should look something like this:

[LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]
public object FunctionHandler(ContactRequest input, ILambdaContext context)
{
    // Function code here.
    return new { location = _appSettings.ReturnUrl };
}

Once deployed you will need to update the environment variables for your particular email addresses and SMTP server settings. I am using Amazon SES to send my emails but you can use any SMTP server.

Setting up Amazon API Gateway

So by now, you should have a lambda function that when invoked on the command line it will send you an email. You should be able to call your function with this and get an email:

dotnet lambda invoke-function lambda-contact-form -payload "{ 'name': 'Alex Hyett', 'email': 'hello@alexhyett.com', 'phone': '0123456789', 'website': 'https://www.alexhyett.com', 'body': 'This is a message'}"

You should then get back a JSON payload with the URL you want to redirect to:

Payload:
{"location":"https://www.alexhyett.com/thank-you"}

This all very good but isn’t an endpoint yet so we can’t use it for our contact form. We are going to use Amazon’s API Gateway to create the endpoint which will call our lambda function.

Our lambda function only accepts application/json as a content type. However, as we are calling this from a form submission we are going to need to convert application/x-www-form-urlencoded to application/json in API Gateway before passing to our Lambda function.

Finally, we are going to get API Gateway to return a 302 redirect to our chosen page so users see a nice page after clicking submit.

Create the API

Using the service’s search in the AWS Console find the API Gateway page.

API Gateway

After clicking Get Started you can then Create your API.

New API

Once created use the Actions menu to Create a Method and choose POST. Then search for your lambda function in the box provided.

Method Setup

Click OK on the popup to give permission for API Gateway to call your lambda function.

Converting application/x-www-form-urlencoded to application/json

On the next screen, you will see all the steps that go into calling your Lambda Function.

Method Execution

We are going to update the Integration Request so that it converts the application/x-www-form-urlencoded it receives into application/json before passing it on to our Lambda function.

Click on Integration Request and scroll down to Mapping Templates. Choose the recommended “When there are no templates defined” option and add application/x-www-form-urlencoded as the Content-Type.

For the template, we are going to use this fantastic gist by fellow developer Ryan Ray.

Just in case it goes offline you can find it here but please check the gist for an up to date version.

## convert HTML POST data or HTTP GET query string to JSON

## get the raw post data from the AWS built-in variable and give it a nicer name
#if ($context.httpMethod == "POST")
 #set($rawAPIData = $input.path('$'))
#elseif ($context.httpMethod == "GET")
 #set($rawAPIData = $input.params().querystring)
 #set($rawAPIData = $rawAPIData.toString())
 #set($rawAPIDataLength = $rawAPIData.length() - 1)
 #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))
 #set($rawAPIData = $rawAPIData.replace(", ", "&"))
#else
 #set($rawAPIData = "")
#end

## first we get the number of "&" in the string, this tells us if there is more than one key value pair
#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())

## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawAPIData + "&")
#end

## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawAPIData.split("&"))

## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])

## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  #if ($kvTokenised[0].length() > 0)
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end

## next we set up our loop inside the output structure "{" and "}"
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
 "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end
#end
}

Your page should now look like this, remember to click save.:

Mapping Template

Finally, we need to set up the response so that it returns a 302 with a Location header pointing to our return page.

Returning 302

Go back to the Method Execution page and click Method Response. We are going to delete the default 200 response and replace it with a 302 with a location header. Your method response should look like this:

Method Response

Go back to Method Execution and click on Integration Response. Again delete the existing 200 response and create a new one with a 302 response status.

You then need to add in a mapping value of integration.response.body.location. The location at the end should match the value returned in your JSON response from your lambda function.

Your response should look like this:

Integration Response

Finally after saving choose Deploy API from the Actions menu. It is worth noting if you update any settings in API Gateway in the future you need to click Deploy API before the changes will take effect.

You will be prompted to create a new deployment stage. Call it something like prod and click Deploy.

Deployment Stage

Once deployed you will get an invoke URL at the top of the page. You should be able to use this as the Action URL on your contact form.

Custom Domain Name

Finally, you might want to use your own domain name for your API. To do this go to Custom Domain Names on the side and enter in the domain you want to use. Such as api.yourdomain.com.

You will need to have a certificate set up for your domain using ACM for the region us-east-1 so that SSL will work.

Custom Domain Name

Once saved it will take about 40 mins to initialise and start working. You will, however, get a CloudFront Url which you will need to add to Route 53 as an A Record Alias.

Finally, you need to add a base path mapping to your endpoint. I have used /contact for mine.

Base Path Endpoint

Once initialised you should be able to replace your URL with https://api.yourdomain.com/contact.

Conclusion

Using AWS Lambda functions is a great way to create small bits of functionality that scales well. This can either be standalone like this method or part of a larger micro-services architecture. It is also possible to host a full .Net Core API with Lambda and API Gateway but I will save that for another post!



Alex Hyett
WRITTEN BY

Alex Hyett

Software Developer, Founder of GrowRecruit, Entrepreneur, Father, and Husband. @thealexhyett. Currently Technical Lead at Checkout.com.