Richard Grantham
Antique wall phones

A Serverless Contact Form using CloudFormation

Filed under AWS on

I work in the AWS ecosystem on a daily basis as a software developer. I get a lot of exposure to developing solutions using AWS technologies. I don’t have a huge amount to do with the dev ops side of things. There are specific teams developing tooling for that. When I decided to build a personal website, I wanted to use it as a learning experience for the things I don’t usually have to touch. It’s more effort than running a traditional CMS/blogging platform, but very rewarding to build something yourself.

This is how I end up building a static website using Gridsome. It’s running on Amplify as opposed to being served from an S3 bucket. I like that there’s an automatic process that builds and deploys the site on a push to the master branch. Building a blog this way means providing a lot of functions yourself. For example, building a contact form so that people can get it touch. You might ask why I don’t just put my socials out there. I don’t actually have any to share.

It’s not particularly hard to setup a contact form in AWS and there are several guides out there on how to do it. I even followed one myself. You created a Lambda function that sends an email through SES and expose it through API Gateway. I did want to go a step beyond and transform the steps into a CloudFormation template.

AWS Lambda is Amazon’s serverless computing platform. It allows you to run code without having to set up a server to run it on. The code for sending the email will be written for Lambda in JavaScript for the NodeJS runtime. API Gateway exposes the Lambda function to the internet. Simple Email Service (SES) is a simple email service that sends email, clearly.

CloudFormation is the AWS infrastructure management service. It provides a language you can use to model your infrastructure. From this you can perform repeatable, safe deployments without having to write custom code or manually create resources. And because a template is just a text file, you can subject it to the same source control practices you would any other source code you write. So the benefits to me of taking the time to transform the steps for building a CloudFormation template for a contact form are:

So, what does the Lambda function look like?

I can’t really claim authorship of this, so I will credit the authors of the original guide I found on creating a Lambda-powered contact form. So, thanks to KyleG on Linux Academy for writing a really good article that gave me the kickstart to automate the process with CloudFormation. You can read his post here.

"use strict";
const AWS = require("aws-sdk");
const sesClient = new AWS.SES();

exports.handler = (event, context, callback) => {
  var emailObj = JSON.parse(event.body);
  var params = getEmailMessage(emailObj);
  var sendEmailPromise = sesClient.sendEmail(params).promise();

  var response = {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Headers": "Content-Type",
      "Access-Control-Allow-Origin": "https://" + process.env.WEBSITE,
      "Access-Control-Allow-Methods": "OPTIONS,POST",
    },
  };

  sendEmailPromise
    .then(function (result) {
      console.log(result);
      callback(null, response);
    })
    .catch(function (err) {
      console.log(err);
      response.statusCode = 500;
      callback(null, response);
    });
};

function getEmailMessage(emailObj) {
  var emailRequestParams = {
    Destination: {
      ToAddresses: [process.env.RECIPIENT],
    },
    Message: {
      Body: {
        Text: {
          Data: emailObj.message,
        },
      },
      Subject: {
        Data:
          "Message from " +
          emailObj.name +
          " - Sent via " +
          process.env.WEBSITE,
      },
    },
    Source: process.env.RECIPIENT,
    ReplyToAddresses: [emailObj.email],
  };

  return emailRequestParams;
}

There are only really a few differences between my script and Kyle’s. I have externalised the recipient email address to an environmental variable named RECIPIENT. I have also made reference to an environmental variable named WEBSITE which tells me the origin of the message. This would be useful should I reuse the script in a different context.

I’ve also used the WEBSITE environmental variable in populating the CORS headers added to the response. This is required because the API Gateway method cannot do this to proxied Lambda function calls.

Finally, the payload is different. I’ve decided to build the subject line in the script and ask for a name instead. My payload looks like this:

{
    "name": <contact-name>,
    "email": <contact-email>,
    "message": <contact-message>
}

What goes into the CloudFormation template?

Let’s have a closer look into what is in the template.

Template parameters

There are a number of parameters that the CloudFormation template makes use of in order to setup the stack. Some have sensible defaults that can be overridden. The rest are provided at creation time, unless you add them to the template.

NameDescription
StageNameDefaults to contact. While this should refer to the lifecycle state of an API, here I am using it as the contact form endpoint.
BucketThis is the name of the S3 bucket into which you have uploaded the zip file containing the Lambda function.
LambdaRuntimeThe Lambda function runtime. I’ve defaulted this to nodejs12.x because the Lambda function is written for this runtime.
SendEmailSourceThe path in the bucket to the zip file containing contact-form.js, the Lambda function source. Default is artefacts/contact-form/contact-form-1.0.0.zip. I will be uploading the zip file to my bucket in the directory path artefacts/contact-form. The zip file is named contact-form-1.0.0.zip. I’m thinking ahead for any changes I make to the script. I could develop a build process that deploys a zip file with an incremented build number to this directory. Deployment using the CloudFormation template would be simple.
SendEmailHandlerThe handler method that is run when the Lambda function is executed. Default is contact-form.handler.
RecipientThe email address to which to send contact form requests.
WebsiteYour website URL. This is used to enable CORS so that your website can call the endpoint.

The parameter AWS::StackName is the name of the stack being created. It’s used to give resources relevant names that associate them with the stack.

A note about your recipient email address

Your recipient email address needs to be verified with AWS. You can do this in the Simple Email Service section of the AWS Console. Apparently you can automate this in CloudFormation, but that’s beyond the scope of this article.

Lambda function security policies

The Lambda function will be making use of SES to send email, so we will need a policy for that. I’ve also included an X-Ray policy for tracing. Finally, the security role includes a policy for recording log events.

LambdaTracingPolicy:
  Type: AWS::IAM::ManagedPolicy
  Properties:
    Description: Allows lambda functions to use xray tracing
    PolicyDocument:
      Version: 2012-10-17
      Statement:
        - Action:
            - xray:PutTraceSegments
            - xray:PutTelemetryRecords
          Effect: Allow
          Resource: "*"

SendEmailPolicy:
  Type: AWS::IAM::ManagedPolicy
  Properties:
    Description: Allows policy holder to send email
    PolicyDocument:
      Version: 2012-10-17
      Statement:
        - Action:
            - ses:SendEmail
            - ses:SendRawEmail
          Effect: Allow
          Resource: "*"

SendEmailLambdaRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Version: 2012-10-17
      Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action:
            - sts:AssumeRole
    ManagedPolicyArns:
      - !Ref LambdaTracingPolicy
      - !Ref SendEmailPolicy
    Policies:
      - PolicyDocument:
          Version: 2012-10-17
          Statement:
            - Action:
                - logs:CreateLogGroup
                - logs:CreateLogStream
                - logs:PutLogEvents
              Effect: Allow
              Resource:
                - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-send-email-function:*"
        PolicyName: !Sub "${AWS::StackName}-send-email-function-log-group-policy"

The Lambda function definition

The Lambda function definition makes use of many of the template parameters.

SendEmailFunction:
  Type: AWS::Lambda::Function
  Properties:
    FunctionName: !Sub "${AWS::StackName}-send-email-function"
    Runtime: !Ref LambdaRuntime
    Handler: !Ref SendEmailHandler
    Role: !GetAtt SendEmailLambdaRole.Arn
    Code:
      S3Bucket: !Ref Bucket
      S3Key: !Ref SendEmailSource
    Timeout: 25
    TracingConfig:
      Mode: Active
    Environment:
      Variables:
        RECIPIENT: !Ref Recipient
        WEBSITE: !Ref Website
    Tags:
      - Key: CostCode
        Value: !Ref AWS::StackName

The API Gateway Definition

Next, we define the API Gateway. Again, the stack name is incorporated into the resource name. I have also added a tag for tracking costs.

ApiGateway:
  Type: AWS::ApiGateway::RestApi
  Properties:
    Name: !Sub "${AWS::StackName}-api"
    Description: "Contact form API"
    Tags:
      - Key: CostCode
        Value: !Ref AWS::StackName

Linking the API Gateway and Lambda function together

There are several resources that need to be created to allow the Lambda function to be called externally through API Gateway. Firstly, let’s start with a permission.

SendEmailLambdaApiGatewayInvoke:
  Type: AWS::Lambda::Permission
  Properties:
    Action: lambda:InvokeFunction
    FunctionName: !GetAtt SendEmailFunction.Arn
    Principal: apigateway.amazonaws.com
    SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/POST/

This permission allows SendEmailFunction defined above to be invoked from the ApiGateway we just defined.

Next we define the contact form endpoint as a method in the API Gateway.

ApiGatewayModel:
  Type: AWS::ApiGateway::Model
  Properties:
    ContentType: "application/json"
    RestApiId: !Ref ApiGateway
    Schema: {}

SendEmailEndpoint:
  Type: AWS::ApiGateway::Method
  Properties:
    AuthorizationType: NONE
    HttpMethod: POST
    Integration:
      IntegrationHttpMethod: POST
      Type: AWS_PROXY
      Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SendEmailFunction.Arn}/invocations"
    MethodResponses:
      - ResponseModels:
          application/json: !Ref ApiGatewayModel
        StatusCode: 200
      - ResponseModels:
          application/json: !Ref ApiGatewayModel
        StatusCode: 500
    ResourceId: !GetAtt ApiGateway.RootResourceId
    RestApiId: !Ref ApiGateway

There’s a little to go through here. The ApiGatewayModel defines the response the Lambda function returns. For simplicity’s sake it’s an empty body. It’s associated with ApiGateway.

The SendEmailEndpoint method defines a POST method that requires no authentication. It acts as a proxy to the SendEmailFunction Lambda function. It’s response and error models are both the ApiGatewayModel we just defined. It’s also associated with ApiGateway.

An interjection on CORS

For the moment I don’t have a subdomain for any APIs I create for the website. I will be just invoking the API Gateway URL. In order for this to work I will need to CORS-enable the endpoint. I noted above that the Lambda function contains code to add the appropriate CORS headers. This is because API Gateway does not transform the response coming from proxied invocations. You need to manually add them in your code. This is not necessarily enough. It’s common practice to make a call to the endpoint using the OPTIONS HTTP method to ensure that CORS is supported. We need to setup an endpoint to enable this.

SendEmailOptionsMethod:
  Type: AWS::ApiGateway::Method
  Properties:
    AuthorizationType: NONE
    RestApiId: !Ref ApiGateway
    ResourceId: !GetAtt ApiGateway.RootResourceId
    HttpMethod: OPTIONS
    Integration:
      IntegrationResponses:
        - StatusCode: 200
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
            method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
            method.response.header.Access-Control-Allow-Origin: !Sub "'https://${Website}'"
          ResponseTemplates:
            application/json: ""
      PassthroughBehavior: WHEN_NO_MATCH
      RequestTemplates:
        application/json: '{"statusCode": 200}'
      Type: MOCK
    MethodResponses:
      - StatusCode: 200
        ResponseModels:
          application/json: "Empty"
        ResponseParameters:
          method.response.header.Access-Control-Allow-Headers: false
          method.response.header.Access-Control-Allow-Methods: false
          method.response.header.Access-Control-Allow-Origin: false

You’ll see that this is a MOCK endpoint. This means it is an endpoint that will return a response without needing to link it to a real function. Useful for development, but also for this use case.

API Gateway deployment

The final resources we need to define concern deploying ApiGateway with the endpoints we’ve defined. This is done with a deployment stage and a AWS::ApiGateway::Deployment resource. This deploys the endpoints to the stage so they may be called over the internet.

ApiGatewayStage:
  Type: AWS::ApiGateway::Stage
  Properties:
    DeploymentId: !Ref SendEmailEndpointDeployment
    RestApiId: !Ref ApiGateway
    StageName: !Ref StageName
    MethodSettings:
      - HttpMethod: "*"
        ResourcePath: "/*"
        ThrottlingBurstLimit: 10
        ThrottlingRateLimit: 100

SendEmailEndpointDeployment:
  Type: AWS::ApiGateway::Deployment
  DependsOn:
    - SendEmailEndpoint
    - SendEmailOptionsMethod
  Properties:
    RestApiId: !Ref ApiGateway

This can be simplified in CloudFormation where you don’t need to define a deployment stage. I decided to add it so that I may define some throttling on the endpoint so I don’t get spammed by bots or angry people. From an architecture standpoint it’s essential to consider such things up front rather than further down the line.

Conclusion and next steps

So, this is how I set up my contact form. There are a few improvements I could make and may do down the line:

Hope you found this helpful. I certainly enjoyed the process of creating something for me outside of my day job.

The code for this post can be found here. I found the following pages useful in creating my CloudFormation template:

Banner image by Pavan Trikutam on Unsplash