← Back to projects

Serverless Contact Form with Lambda, API Gateway and Amazon SES

The contact form on my portfolio uses a serverless email workflow on AWS. A recruiter can submit a message, I receive it in my inbox, and the recruiter receives a confirmation from noreply@pavankrishna.dev.

AWS Lambda API Gateway Amazon SES Python 3.12 IAM CORS DKIM & DMARC Namecheap DNS Calendly
Architecture Visualization
🧑‍💼
Contact Form Recruiter submits
🌐
API Gateway POST /contact
AWS Lambda Validate and process
📧
Amazon SES Send two emails
📥
Email Delivery Message and receipt

One form submission sends the message to my inbox and a confirmation to the recruiter without running a permanent backend server.

The Strategic Why

Why build this instead of using a form service?

Hosted form services can process contact messages quickly, but this portfolio is also a demonstration of my cloud engineering work. Building the workflow on AWS gave me practical experience with Lambda execution roles, API Gateway, CORS, SES domain verification and email authentication.

Amazon SES also lets the confirmation email come from noreply@pavankrishna.dev instead of an unrelated third-party domain. That small detail makes the interaction feel more consistent and professional.

The visible form is simple, but the workflow behind it demonstrates security, serverless integration, domain authentication, error handling and production email delivery.
Execution Timeline

Step by step breakdown

The implementation progressed from a verified SES identity to a complete contact and scheduling experience.

01

Verified an email address in SES

I first verified my email address in Amazon SES. During sandbox testing, SES requires verified identities before it can deliver messages.

02

Created the Lambda function

I created a Python 3.12 function that validates the submitted fields, sends the message to me, and sends a confirmation to the recruiter.

03

Granted SES permissions

I updated the Lambda execution role with the permissions required to send email through SES. This allows the function to call SES without using account credentials in the code.

04

Created the API Gateway routes

I created a POST /contact route for form submissions and an OPTIONS /contact route for browser preflight checks. Both routes connect to the Lambda function.

05

Configured CORS

I restricted requests to pavankrishna.dev and allowed the content-type header plus the POST and OPTIONS methods. The configuration is managed consistently at the API layer.

06

Verified the portfolio domain

I verified pavankrishna.dev in SES so the workflow could send from noreply@pavankrishna.dev instead of a personal Gmail address.

07

Added DKIM and DMARC records

I added three DKIM CNAME records and one DMARC TXT record in Namecheap. These records help receiving mail servers authenticate messages from the domain.

08

Moved SES out of sandbox mode

I submitted the production access request with the portfolio use case. After approval, SES could send the confirmation email to recruiters who had not been pre-verified.

09

Connected the portfolio interface

I connected the form to API Gateway, added clear success and failure states, and included Calendly as an alternative for recruiters who prefer to schedule a call.

DNS and Email Authentication

How DKIM and DMARC support delivery

SES generated three DKIM records for the domain. DKIM adds a cryptographic signature to outgoing email so the receiving server can verify that the message genuinely came from pavankrishna.dev and was not modified during delivery.

I also added a DMARC policy. DMARC tells receiving mail systems how to handle messages that fail domain authentication and provides a clear policy for protecting the sender identity.

Type Host format Purpose
CNAME [generated-key-1]._domainkey First DKIM signing record
CNAME [generated-key-2]._domainkey Second DKIM signing record
CNAME [generated-key-3]._domainkey Third DKIM signing record
TXT _dmarc DMARC authentication policy

I copied the values generated by SES into Namecheap Advanced DNS. Namecheap appends the domain automatically, so only the generated host portion belongs in the Host field. After DNS propagation, SES reported the domain as verified.

SES Access Modes

Understanding sandbox and production access

New SES accounts begin in a restricted sandbox. This protects the service from abuse while the account owner verifies identities and demonstrates a legitimate email use case.

Sandbox mode

  • Messages can only be sent to verified recipients.
  • Sending quotas are intentionally limited.
  • A public contact form cannot confirm arbitrary recruiter addresses.
  • The workflow is suitable for controlled testing.

Production access

  • Messages can be sent to normal recipient addresses.
  • AWS assigns production sending quotas to the account.
  • The confirmation workflow works for recruiters.
  • Domain authentication remains part of safe delivery.

My production request described a low-volume personal portfolio contact form. AWS approved the request after the domain verification was complete.

The Code

Lambda function code

The function handles preflight requests, validates the submitted fields, sends two emails, and returns a clear HTTP response to the frontend.

lambda_function.py - Python 3.12
import json import boto3 ses = boto3.client('ses', region_name='eu-central-1') RECIPIENT = 'pavankrishnaer@gmail.com' SENDER = 'noreply@pavankrishna.dev' def lambda_handler(event, context): method = event.get('requestContext', {}).get('http', {}).get('method') # Return a successful response to the browser preflight check. if method == 'OPTIONS': return {'statusCode': 200, 'body': ''} try: body = json.loads(event.get('body', '{}')) name = body.get('name', '').strip() email = body.get('email', '').strip() company = body.get('company', '').strip() message = body.get('message', '').strip() if not name or not email or not message: return { 'statusCode': 400, 'body': json.dumps({'error': 'Name, email and message are required.'}) } company_line = f'Company: {company}\n' if company else '' # Send the submitted message to my inbox. ses.send_email( Source=SENDER, Destination={'ToAddresses': [RECIPIENT]}, Message={ 'Subject': {'Data': f'Portfolio contact from {name}'}, 'Body': { 'Text': { 'Data': f'Name: {name}\nEmail: {email}\n{company_line}\nMessage:\n{message}' } } }, ReplyToAddresses=[email] ) # Confirm that the recruiter's message was received. ses.send_email( Source=SENDER, Destination={'ToAddresses': [email]}, Message={ 'Subject': {'Data': 'Thanks for reaching out to Pavankrishna'}, 'Body': { 'Text': { 'Data': f'Hi {name},\n\nI received your message and will reply within 24 hours.\n\nYour message:\n{message}\n\nBest regards,\nPavankrishna Ellore Ramesh' } } } ) return { 'statusCode': 200, 'body': json.dumps({'success': True}) } except Exception as error: return { 'statusCode': 500, 'body': json.dumps({'error': str(error)}) }

Why use ReplyToAddresses? It lets me reply directly to the recruiter from the message in my inbox, even though SES sends the original notification from noreply@pavankrishna.dev.

Why the OPTIONS preflight request matters

The portfolio and API Gateway use different domains. Before the browser sends the cross-origin POST request, it sends an OPTIONS request to confirm that the API accepts the origin, header and method.

The API must return a successful response with the expected CORS policy. If that check fails, the browser blocks the form submission before the POST request reaches the application logic.

Calendar Booking

Why I added Calendly beside the form

Recruiters do not all prefer the same contact method. The portfolio therefore offers a written message form and an embedded scheduling option.

01

Configured availability

I created a meeting type, set the available hours, and connected the calendar so occupied times are not offered.

02

Added the inline embed

Calendly provides an inline widget that renders the scheduling experience inside the contact section without forcing an immediate redirect.

03

Used a tabbed contact layout

The recruiter can switch between Write a message and Schedule a call. Calendly loads only when the calendar option is selected, which reduces unnecessary third-party loading.

Obstacles & Solutions

Challenges and how I fixed them

Problem

An email address was missing quotes

CloudWatch reported a Python NameError because an email address had been written as if it were a variable instead of a string.

Solution

I wrapped the address in quotes and redeployed the function. The CloudWatch error identified the exact line, which made the correction straightforward.

Problem

The browser rejected the preflight response

The form was blocked because the OPTIONS request did not receive a successful response before the browser attempted the POST request.

Solution

I added explicit OPTIONS handling and verified the API Gateway CORS configuration. The browser then allowed the POST request to continue.

Problem

CORS was configured in two places

CORS headers existed in both Lambda responses and API Gateway, which produced inconsistent behaviour and made troubleshooting harder.

Solution

I established one source of truth for the CORS policy at the API layer and kept the application response focused on status and data.

Problem

Confirmation emails failed in sandbox mode

SES rejected confirmation messages to recruiter addresses because those recipients were not verified identities in the sandbox.

Solution

I completed domain authentication and requested production access. After approval, the workflow could send confirmations to normal recruiter addresses.

Problem

The production access control was disabled

The SES console did not allow a production request before the domain identity had completed verification.

Solution

I finished the DKIM verification for pavankrishna.dev. The production request became available after SES recognised the verified domain.

Financial Breakdown

What does this cost to operate?

The workflow is usage based. At the low message volume of a personal portfolio, the operational cost remains close to zero.

Service How it is used Current impact
AWS Lambda Processes each contact request Near €0
API Gateway Exposes the HTTPS contact endpoint Near €0
Amazon SES Sends the notification and confirmation Near €0
IAM Controls service permissions €0
Total Low-volume serverless contact workflow Near €0

Actual charges depend on AWS pricing, region, account allowances and message volume.

Key Technical Takeaways

What I learned from this build

✉️

Email delivery has several trust layers

Reliable delivery depends on verified identities, DKIM, DMARC, account access and correct sender configuration.

🔍

CloudWatch shortens debugging time

The Lambda logs exposed concrete Python and SES errors, which was more effective than guessing from a generic frontend failure.

🌐

Preflight is a separate HTTP exchange

The browser must approve the CORS policy through OPTIONS before it sends the actual cross-origin form request.

🔒

Service restrictions protect shared infrastructure

The SES sandbox encourages domain verification and responsible sending practices before broader delivery is enabled.

🏷️

Domain email strengthens presentation

A confirmation from the portfolio domain creates a more coherent experience than a message from an unrelated sender address.

⚙️

Each policy needs one source of truth

Managing CORS in one layer prevents conflicting headers and makes the request flow easier to understand and maintain.