avatarAndre Lopes

Summary

The provided content outlines a comprehensive guide to building a serverless API with email notifications in AWS using Terraform, detailing the implementation of SQS, SNS, Lambda, and SES to handle CRUD operations and send email updates.

Abstract

The text is a technical tutorial that guides the reader through the process of enhancing a serverless CRUD API on AWS with email notification capabilities. It begins by introducing the concept of a fan-out architecture using SNS and SQS to decouple services and ensure scalability. The author then walks through setting up the necessary AWS resources using Terraform, including SNS topics, SQS queues, and IAM policies for

SQS triggered lambda to send email through SES

Building a serverless API with email notifications in AWS with Terraform — Part 3

Triggering our notification lambda with SNS-SQS fanout pattern so it can send an email with SES

Photo by Emile Perron on Unsplash

Hi people!

Check part 1 here and part 2 here.

In the previous parts of our story, we built a CRUD serverless API and reached the following end architecture:

A full working serverless CRUD API that stores data in DynamoDB.

Now, it is time to finish our project.

The final part

In this part, we’ll add SNS, SQS, and a Lambda to process changes to our movie database and notify via email.

SNS stands for Simple Notification System. It is an AWS fully-managed service that sends notification messages from publishers to subscribers.

SQS stands for Simple Queue Service. It is an AWS fully-managed message queue service where we can send messages that a consumer can asynchronously process.

Combining both is useful for implementing a microservices architecture because it allows your systems to communicate asynchronously.

And lastly, we’ll make our lambda be triggered by a new SQS message and send an email through SES.

This will be the final architecture for this session:

Requirements

  • An AWS account
  • Any Code Editor of your choice — I use Visual Studio Code
  • NodeJS
  • GitHub account — We’ll be using GitHub Actions to deploy our Terraform code

Let’s begin

We will add our SNS topic and SQS queue to our Terraform code.

First, in the iac folder, create a new file named messaging.tf and add the following code to generate our SNS and SQS:

resource "aws_sns_topic" "movie_updates" {
  name = "movie-updates-topic"
}

resource "aws_sqs_queue" "movie_updates_queue" {
  name   = "movie-updates-queue"
  policy = data.aws_iam_policy_document.sqs-queue-policy.json
}

resource "aws_sns_topic_subscription" "movie_updates_sqs_target" {
  topic_arn            = aws_sns_topic.movie_updates.arn
  protocol             = "sqs"
  endpoint             = aws_sqs_queue.movie_updates_queue.arn
  raw_message_delivery = true
}

data "aws_iam_policy_document" "sqs-queue-policy" {
  policy_id = "arn:aws:sqs:${var.region}:${var.account_id}:movie-updates-queue/SQSDefaultPolicy"

  statement {
    sid    = "movie_updates-sns-topic"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["sns.amazonaws.com"]
    }

    actions = [
      "SQS:SendMessage",
    ]

    resources = [
      "arn:aws:sqs:${var.region}:${var.account_id}:movie-updates-queue",
    ]

    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"

      values = [
        aws_sns_topic.movie_updates.arn,
      ]
    }
  }
}

We chose the fan-out architecture over directly publishing a message to SQS because this allows us to easily expand our microservices architecture in case we need more services to be notified about any message coming from this SNS topic.

Because SNS broadcasts notification events to all subscriptions, it enables easy expansion.

SNS fan-out pattern (source: AWS)

Now, run the GitHub workflow to create our queue and topic

Publishing events to the SNS topic

To allow our lambdas to publish the events to SNS, we first need to give them access through IAM policies.

To do that, add the following code to the iam-policies.tf file in the iac folder:

data "aws_iam_policy_document" "publish_to_movies_updates_sns_topic" {
  statement {
    effect = "Allow"

    actions = [
      "sns:Publish",
    ]

    resources = [
      aws_sns_topic.movie_updates.arn
    ]
  }
}

resource "aws_iam_policy" "publish_to_movies_updates_sns_topic" {
  name        = "publish_to_movies_updates_sns_topic"
  path        = "/"
  description = "IAM policy allowing to PUBLISH events to ${aws_sns_topic.movie_updates.name}"
  policy      = data.aws_iam_policy_document.publish_to_movies_updates_sns_topic.json
}

resource "aws_iam_role_policy_attachment" "allow_publish_to_movies_update_sns_create_movie_lambda" {
  role       = module.create_movie_lambda.role_name
  policy_arn = aws_iam_policy.publish_to_movies_updates_sns_topic.arn
}

resource "aws_iam_role_policy_attachment" "allow_publish_to_movies_update_sns_delete_movie_lambda" {
  role       = module.delete_movie_lambda.role_name
  policy_arn = aws_iam_policy.publish_to_movies_updates_sns_topic.arn
}

resource "aws_iam_role_policy_attachment" "allow_publish_to_movies_update_sns_update_movie_lambda" {
  role       = module.update_movie_lambda.role_name
  policy_arn = aws_iam_policy.publish_to_movies_updates_sns_topic.arn
}

This will allow them to perform the Publish action in our SNS topic, which will broadcast the event and be picked up by our SQS queue.

Publishing events

Now, to code, let’s start with our create-movie lambda. It will send an event every time we add a new movie.

MovieCreated event

Now, go to the apps/create-movie folder. In the models.go file, let’s add a struct that will represent our event

package main

type Request struct {
 Title  string   `json:"title"`
 Rating float64  `json:"rating"`
 Genres []string `json:"genres"`
}

type Response struct {
 ID     string   `json:"id"`
 Title  string   `json:"title"`
 Rating float64  `json:"rating"`
 Genres []string `json:"genres"`
}

type ErrorResponse struct {
 Message string `json:"message"`
}

type Movie struct {
 ID     string   `dynamodbav:",string"`
 Title  string   `dynamodbav:",string"`
 Genres []string `dynamodbav:",stringset,omitemptyelem"`
 Rating float64  `dynamodbav:",number"`
}

type MovieCreated struct {
 ID     string   `json:"id"`
 Title  string   `json:"title"`
 Rating float64  `json:"rating"`
 Genres []string `json:"genres"`
}

func (event *MovieCreated) getEventName() string {
 return "MovieCreated"
}

Ideally, you’ll want to have these events in a shared package so consumers can use them.

Now, let’s edit our main.go file to publish the event every time we create a new movie:

package main

import (
 "context"
 "encoding/json"
 "fmt"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/aws/aws-sdk-go/aws"
 "github.com/aws/aws-sdk-go/aws/session"
 "github.com/aws/aws-sdk-go/service/dynamodb"
 "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
 "github.com/aws/aws-sdk-go/service/sns"
 "github.com/google/uuid"
)

func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
 var newMovie Request
 err := json.Unmarshal([]byte(request.Body), &newMovie)

 if err != nil {
  response, _ := json.Marshal(ErrorResponse{
   Message: "Got error marshalling new movie item, " + err.Error(),
  })

  return events.APIGatewayProxyResponse{
   Body:       string(response),
   StatusCode: 500,
  }, nil
 }

 sess := session.Must(session.NewSessionWithOptions(session.Options{
  SharedConfigState: session.SharedConfigEnable,
 }))

 // Create DynamoDB client
 dynamoDbService := dynamodb.New(sess)

 item := Movie{
  ID:     uuid.NewString(),
  Title:  newMovie.Title,
  Genres: newMovie.Genres,
  Rating: newMovie.Rating,
 }

 av, err := dynamodbattribute.MarshalMap(item)
 if err != nil {
  response, _ := json.Marshal(ErrorResponse{
   Message: "Got error marshalling new movie item to DynamoAttribute, " + err.Error(),
  })

  return events.APIGatewayProxyResponse{
   Body:       string(response),
   StatusCode: 500,
  }, nil
 }

 // Create item in table Movies
 tableName := "Movies"

 input := &dynamodb.PutItemInput{
  Item:      av,
  TableName: aws.String(tableName),
 }

 _, err = dynamoDbService.PutItem(input)
 if err != nil {
  response, _ := json.Marshal(ErrorResponse{
   Message: "Got error calling PutItem, " + err.Error(),
  })

  return events.APIGatewayProxyResponse{
   Body:       string(response),
   StatusCode: 500,
  }, nil
 }

 publishEventToSNS(sess, item)

 responseData := Response{
  ID:     item.ID,
  Title:  item.Title,
  Genres: item.Genres,
  Rating: item.Rating,
 }

 responseBody, err := json.Marshal(responseData)

 response := events.APIGatewayProxyResponse{
  Body:       string(responseBody),
  StatusCode: 200,
 }

 return response, nil
}

func publishEventToSNS(sess *session.Session, item Movie) {
 snsService := sns.New(sess)

 movieCreatedEvent := MovieCreated{
  ID:     item.ID,
  Title:  item.Title,
  Rating: item.Rating,
  Genres: item.Genres,
 }

 eventJSON, err := json.Marshal(movieCreatedEvent)

 _, err = snsService.Publish(&sns.PublishInput{
  Message: aws.String(string(eventJSON)),
  MessageAttributes: map[string]*sns.MessageAttributeValue{
   "Type": {
    DataType:    aws.String("String"),
    StringValue: aws.String(movieCreatedEvent.getEventName()),
   },
  },
  TopicArn: aws.String("YOUR_SNS_TOPIC_ARN"), // Add your topic ARN here
 })

 if err != nil {
  fmt.Println(err.Error())
 }
}

func main() {
 lambda.Start(handleRequest)
}

Don’t forget to change the YOUR_SNS_TOPIC_ARN to the topic ARN that was created in the previous section through Terraform.

To test it, you can create a new movie through the POST /movies endpoint, go to the SQS queue and poll for messages to see it there:

When you click on it, you can see the body:

And the attributes:

MovieDeleted event

Now, let’s move to send a deleted event through our delete-movie lambda.

Go to the apps/delete-movie folder and then run the following npm command to add the SNS library:

npm i -s @aws-sdk/client-sns

Now, create a new models.ts file in the src folder to add our event type:

export type MovieDeleted = {
  id: string;
};

And now, let’s publish the message to SNS in the index.ts file:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, DeleteCommand } from "@aws-sdk/lib-dynamodb";
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { PublishCommand, SNSClient } from "@aws-sdk/client-sns";
import { MovieDeleted } from "./models.js";

const tableName = "Movies";

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  const movieID = event.pathParameters?.movieID;

  if (!movieID) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        message: "Movie ID missing",
      }),
    };
  }

  console.log("Deleting movie with ID ", movieID);

  const client = new DynamoDBClient({});
  const docClient = DynamoDBDocumentClient.from(client);

  const command = new DeleteCommand({
    TableName: tableName,
    Key: {
      ID: movieID.toString(),
    },
  });

  try {
    await docClient.send(command);

    await publishEventToSNS(movieID);

    return {
      statusCode: 204,
      body: JSON.stringify({
        message: `Movie ${movieID} deleted`,
      }),
    };
  } catch (e: any) {
    console.log(e);

    return {
      statusCode: 500,
      body: JSON.stringify({
        message: e.message,
      }),
    };
  }
};

async function publishEventToSNS(movieID: string) {
  const snsClient = new SNSClient({});

  const event: MovieDeleted = {
    id: movieID,
  };

  const eventName = "MovieDeleted";

  try {
    await snsClient.send(
      new PublishCommand({
        Message: JSON.stringify(event),
        TopicArn: "YOUR_SNS_TOPIC_ARN", // Add your SNS topic ARN here
        MessageAttributes: {
          Type: {
            DataType: "String",
            StringValue: eventName,
          },
        },
      })
    );
  } catch (e: any) {
    console.warn(e);
  }
}

Don’t forget to change YOUR_SNS_TOPIC_ARN value to your actual SNS topic ARN.

Now, push it to GitHub, wait for the action to succeed, and then delete an existing movie through the PUT /movies/{movieID} endpoint and check SQS for the message in the queue.

MovieUpdated event

And now for our last lambda, the update-movie lambda.

Go to apps/update-movie folder and modify the models.go to add the MovieUpdated event:

package main

type Request struct {
 Title  string   `json:"title"`
 Rating float64  `json:"rating"`
 Genres []string `json:"genres"`
}

type ErrorResponse struct {
 Message string `json:"message"`
}

type MovieData struct {
 Title  string   `dynamodbav:":title,string" json:"title"`
 Genres []string `dynamodbav:":genres,stringset,omitemptyelem"  json:"genres"`
 Rating float64  `dynamodbav:":rating,number"  json:"rating"`
}

type Movie struct {
 ID     string   `json:"id"`
 Title  string   `json:"title"`
 Genres []string `json:"genres"`
 Rating float64  `json:"rating"`
}

type MovieUpdated struct {
 ID     string   `json:"id"`
 Title  string   `json:"title"`
 Rating float64  `json:"rating"`
 Genres []string `json:"genres"`
}

func (event *MovieUpdated) getEventName() string {
 return "MovieUpdated"
}

And now, to add the code to the main.go file:

package main

import (
 "context"
 "encoding/json"
 "fmt"
 "strings"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/aws/aws-sdk-go/aws"
 "github.com/aws/aws-sdk-go/aws/session"
 "github.com/aws/aws-sdk-go/service/dynamodb"
 "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
 "github.com/aws/aws-sdk-go/service/sns"
)

func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
 movieID := request.PathParameters["movieID"]

 if strings.TrimSpace(movieID) == "" {
  response, _ := json.Marshal(ErrorResponse{
   Message: "Movie ID invalid",
  })

  return events.APIGatewayProxyResponse{
   Body:       string(response),
   StatusCode: 400,
  }, nil
 }

 var updateMovie Request
 err := json.Unmarshal([]byte(request.Body), &updateMovie)

 if err != nil {
  response, _ := json.Marshal(ErrorResponse{
   Message: "Got error marshalling update movie item, " + err.Error(),
  })

  return events.APIGatewayProxyResponse{
   Body:       string(response),
   StatusCode: 500,
  }, nil
 }

 sess := session.Must(session.NewSessionWithOptions(session.Options{
  SharedConfigState: session.SharedConfigEnable,
 }))

 // Create DynamoDB client
 dynamoDbService := dynamodb.New(sess)

 movieData := MovieData{
  Title:  updateMovie.Title,
  Genres: updateMovie.Genres,
  Rating: updateMovie.Rating,
 }

 attributeMapping, err := dynamodbattribute.MarshalMap(movieData)

 if err != nil {
  response, _ := json.Marshal(ErrorResponse{
   Message: "Got error marshalling update movie item to DynamoAttribute, " + err.Error(),
  })

  return events.APIGatewayProxyResponse{
   Body:       string(response),
   StatusCode: 500,
  }, nil
 }

 // Create item in table Movies
 tableName := "Movies"

 input := &dynamodb.UpdateItemInput{
  ExpressionAttributeValues: attributeMapping,
  TableName:                 aws.String(tableName),
  Key: map[string]*dynamodb.AttributeValue{
   "ID": {
    S: aws.String(movieID),
   },
  },
  ReturnValues:     aws.String("ALL_NEW"),
  UpdateExpression: aws.String("set Rating = :rating, Title = :title, Genres = :genres"),
 }

 updateResponse, err := dynamoDbService.UpdateItem(input)

 if err != nil {
  errorResponse, _ := json.Marshal(ErrorResponse{
   Message: "Got error calling UpdateItem, " + err.Error(),
  })

  return events.APIGatewayProxyResponse{
   Body:       string(errorResponse),
   StatusCode: 500,
  }, nil
 }

 var movie Movie
 err = dynamodbattribute.UnmarshalMap(updateResponse.Attributes, &movie)

 publishEventToSNS(sess, movie)

 response := events.APIGatewayProxyResponse{
  StatusCode: 200,
 }

 return response, nil
}

func publishEventToSNS(sess *session.Session, item Movie) {
 snsService := sns.New(sess)

 movieUpdatedEvent := MovieUpdated{
  ID:     item.ID,
  Title:  item.Title,
  Rating: item.Rating,
  Genres: item.Genres,
 }

 eventJSON, err := json.Marshal(movieUpdatedEvent)

 _, err = snsService.Publish(&sns.PublishInput{
  Message: aws.String(string(eventJSON)),
  MessageAttributes: map[string]*sns.MessageAttributeValue{
   "Type": {
    DataType:    aws.String("String"),
    StringValue: aws.String(movieUpdatedEvent.getEventName()),
   },
  },
  TopicArn: aws.String("YOUR_SNS_TOPIC_ARN"),
 })

 if err != nil {
  fmt.Println(err.Error())
 }
}

func main() {
 lambda.Start(handleRequest)
}

Don’t forget to change YOUR_SNS_TOPIC_ARN value to your actual SNS topic ARN.

Now, push the code to GitHub, wait for 0the workflow to succeed, and test it by updating a movie and checking back on SQS for the MovieUpdated event.

Processing the SQS messages

Now, let’s build our lambda for processing our event messages in SQS.

Ideally, we’d create one lambda to be responsible for each event type, but for simplicity, we’ll create a generic one to handle all three.

Let’s start by adding a new NodeJS lambda with a SQS trigger to our iac/lambdas.tf file:

module "process_movie_update_events_lambda" {
  source  = "./modules/lambda"
  name    = "process-movie-update-events"
  runtime = "nodejs20.x"
  handler = "index.handler"
}

resource "aws_lambda_event_source_mapping" "movie_update_events_trigger" {
  event_source_arn = aws_sqs_queue.movie_updates_queue.arn
  function_name    = module.process_movie_update_events_lambda.arn
  enabled          = true
}

If you’d like to set filter_criteria , please note that the Lambda Event Filter deletes messages from the Queue when they don’t match the filter criteria. This means the message won’t be available to be polled in the SQS queue anymore if they don’t match the filter criteria.

It is very important to note that lambda trigger filters

We also need to add permissions to this lambda to pull messages from our SQS queue. In the iam-policies.tf add:

data "aws_iam_policy_document" "pull_message_from_sqs" {
  statement {
    effect = "Allow"

    actions = [
      "sqs:ReceiveMessage",
      "sqs:DeleteMessage",
      "sqs:GetQueueAttributes"
    ]

    resources = [
      aws_sqs_queue.movie_updates_queue.arn
    ]
  }
}

Note that if your SQS queue is encrypted with kms, you’ll need to add the kms:Decrypt permission to the policy

Now, push the code to GitHub and wait for the workflow to succeed in creating our lambda and trigger.

You can check if it worked by going to the lambda and seeing the trigger attached to it:

Let’s code our Lambda. In the apps folder, create a new folder named process-movie-update-events and let’s initialize a Typescript project with:

npm init -y
npm i -s typescript

Inside the package.json add the tsc script:

{
  "name": "process-movie-update-events",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "tsc": "tsc",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "typescript": "^5.3.3"
  }
}

Now run the following command to initialize your typescript project:

npm run tsc -- --init --target esnext --module nodenext `
--moduleResolution nodenext --rootDir src `
--outDir build --noImplicitAny --noImplicitThis --newLine lf `
--resolveJsonModule

Create a new folder named src and a file named index.ts.

In the index.ts add the following code:

import { SQSEvent, Context, SQSHandler, SQSRecord } from "aws-lambda";

export const handler: SQSHandler = async (event: SQSEvent, context: Context): Promise<void> => {
  for (const message of event.Records) {
    await processMessageAsync(message);
  }
  console.info("done");
};

async function processMessageAsync(message: SQSRecord): Promise<any> {
  try {
    console.log(`Processed ${message.messageAttributes["Type"].stringValue} message ${message.body}`);
    // TODO: Do interesting work based on the new message
    await Promise.resolve(1); //Placeholder for actual async work
  } catch (err) {
    console.error("An error occurred");
    throw err;
  }
}

This code will be triggered every time a new SQS message is added to our movie-updates-queue. We now need to enable SES and send an email through our lambda.

To do so, create a new file named email.tf in the iac folder. There, add the following code:

# The email here will receive a verification email
# To set it as verified in SES
resource "aws_ses_email_identity" "email_identity" {
  email = "YOUR_EMAIL"
}

# Rules to monitor your SES email sending activity, you can create configuration sets and output them in Terraform.
# Event destinations
# IP pool managemen
resource "aws_ses_configuration_set" "configuration_set" {
  name = "movies-configuration-set"
}

Change the YOUR_EMAIL placeholder for the email you’d like to be the identity that the lambda will use as the source of the email.

If you already have a domain, you can use the aws_ses_domain_identity resource instead, but the verification steps are different. If you have already it registered in Route 53. You can use Terraform to automatically verify it for you with the aws_route53_record resource.

The configuration_set are groups of rules that you can apply to your verified identities.

Once you push it to GitHub and the workflow succeeds, the email provided in the Terraform resource will receive an email from AWS with a link to verify its identity. Click on the link to verify and enable it.

Now, we can go back to our email-notification lambda and finalize our code. Create a models.ts file in the email-notification folder a and add the following code:

export type MovieCreated = {
  id: string;
  title: string;
  rating: number;
  genres: string[];
};

export type MovieDeleted = {
  id: string;
};

export type MovieUpdated = {
  id: string;
  title: string;
  rating: number;
  genres: string[];
};

export const MovieCreatedEventType = "MovieCreated";
export const MovieDeletedEventType = "MovieDeleted";
export const MovieUpdatedEventType = "MovieUpdated

Let’s adapt our index.ts file with the SES code:

import { SQSEvent, Context, SQSHandler, SQSRecord } from "aws-lambda";
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import { MovieCreated, MovieCreatedEventType, MovieDeleted, MovieDeletedEventType, MovieUpdated, MovieUpdatedEventType } from "./models.js";

export const handler: SQSHandler = async (event: SQSEvent, context: Context): Promise<void> => {
  const client = new SESClient({});
  const promises: Promise<void>[] = [];
  for (const message of event.Records) {
    promises.push(processMessageAsync(message, client));
  }

  await Promise.all(promises);

  console.info("done");
};

async function processMessageAsync(message: SQSRecord, client: SESClient): Promise<void> {
  try {
    const eventType = message.messageAttributes["Type"].stringValue ?? "MovieEvent";
    console.log(`Processing ${eventType} message ${message.body}`);

    await sendEmail(message, eventType, client);

    console.log(`Processed ${eventType} message ${message.body}`);
  } catch (err) {
    console.error("An error occurred");
    console.error(err);
  }
}

async function sendEmail(message: SQSRecord, eventType: string, client: SESClient) {
  const [subject, body] = buildSubjectAndBody(message.body, eventType);

  const sourceEmail = "YOUR_SOURCE_EMAIL"; // Ideally it needs to be validated and logged if not set
  const destinationEmail = "YOUR_DESTINATION_EMAIL"; // Ideally it needs to be validated and logged if not set

  const command = new SendEmailCommand({
    Source: sourceEmail,
    Destination: {
      ToAddresses: [destinationEmail],
    },
    Message: {
      Body: {
        Text: {
          Charset: "UTF-8",
          Data: body,
        },
      },
      Subject: {
        Charset: "UTF-8",
        Data: subject,
      },
    },
  });

  await client.send(command);
}

function buildSubjectAndBody(messageBody: string, eventType: string): [string, string] {
  let subject = "";
  let body = "";
  const messageJsonBody = JSON.parse(messageBody);
  switch (eventType) {
    case MovieCreatedEventType:
      const movieCreatedEvent = <MovieCreated>messageJsonBody;

      subject = "New Movie Added: " + movieCreatedEvent.title;
      body = "A new movie was added!\n" +
        "ID: " + movieCreatedEvent.id + "\n" +
        "Title: " + movieCreatedEvent.title + "\n" +
        "Rating: " + movieCreatedEvent.rating + "\n" +
        "Genres: " + movieCreatedEvent.genres;

      break;

    case MovieDeletedEventType:
      const movieDeletedEvent = <MovieDeleted>messageJsonBody;

      subject = "Movie Deleted. ID: " + movieDeletedEvent.id;
      body = "A movie was updated!\n" +
        "ID: " + movieDeletedEvent.id;

      break;

    case MovieUpdatedEventType:
      const movieUpdatedEvent = <MovieUpdated>messageJsonBody;

      subject = "Movie Updated: " + movieUpdatedEvent.title;
      body = "A movie was updated!\n" +
        "ID: " + movieUpdatedEvent.id + "\n" +
        "Title: " + movieUpdatedEvent.title + "\n" +
        "Rating: " + movieUpdatedEvent.rating + "\n" +
        "Genres: " + movieUpdatedEvent.genres;

      break;

    default:
      throw new Error("An unknown movie event was received");
  }

  return [subject, body];
}

Don’t forget to change YOUR_SOURCE_EMAIL to the email set in SES, and YOUR_DESTINATION_EMAIL to the email you’d like to receive these event messages.

We are just left to add a workflow to deploy this lambda. So, let’s add a deploy-email-notification-lambda.yml file in the .github/workflows folder and add the following code to build and deploy your lambda:

name: Deploy Email Notification Lambda
on:
  push:
    branches:
      - main
    paths:
      - apps/email-notification/**/*
      - .github/workflows/deploy-email-notification-lambda.yml

defaults:
  run:
    working-directory: apps/email-notification/

jobs:
  terraform:
    name: "Deploy Email Notification Lambda"
    runs-on: ubuntu-latest
    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Configure AWS Credentials Action For GitHub Actions
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1

      - name: Install packages
        run: npm install

      - name: Build
        run: npm run build

      - name: Zip build
        run: zip -r -j main.zip ./build

      - name: Update Lambda code
        run: aws lambda update-function-code --function-name=email-notification --zip-file=fileb://main.zip

Amazing!

Now, push your code to GitHub and wait for your lambda deployment.

You can create, update, and delete a movie to test it. An email should be received in the destination email with the body and subject set by you in the lambda.

Conclusion

We are at the end of our journey. It has been a pleasure writing this and sharing this knowledge with you.

With this, you learned how easy it is to set up lambdas and email notifications in AWS.

Not only that, but you could also learn how to set up and send messages to SNS and SQS with a fanout pattern to trigger other lambdas.

You also learned how much simpler it can become when you have IaC with Terraform to manage all your cloud resources.

I hope you enjoyed this article as much as I enjoyed writing it for you!

The final code for this project can be found here.

Part 1 here.

Part 2 here.

Happy coding! 💻

Microservices
Lambda
Programming
Message Queue
Event Driven Architecture
Recommended from ReadMedium