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.
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.
One form submission sends the message to my inbox and a confirmation to the recruiter without running a permanent backend server.
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.
The implementation progressed from a verified SES identity to a complete contact and scheduling experience.
I first verified my email address in Amazon SES. During sandbox testing, SES requires verified identities before it can deliver messages.
I created a Python 3.12 function that validates the submitted fields, sends the message to me, and sends a confirmation to the recruiter.
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.
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.
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.
I verified pavankrishna.dev in SES so the workflow could send from noreply@pavankrishna.dev instead of a personal Gmail address.
I added three DKIM CNAME records and one DMARC TXT record in Namecheap. These records help receiving mail servers authenticate messages from the domain.
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.
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.
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.
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.
My production request described a low-volume personal portfolio contact form. AWS approved the request after the domain verification was complete.
The function handles preflight requests, validates the submitted fields, sends two emails, and returns a clear HTTP response to the frontend.
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.
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.
Recruiters do not all prefer the same contact method. The portfolio therefore offers a written message form and an embedded scheduling option.
I created a meeting type, set the available hours, and connected the calendar so occupied times are not offered.
Calendly provides an inline widget that renders the scheduling experience inside the contact section without forcing an immediate redirect.
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.
CloudWatch reported a Python NameError because an email address had been written as if it were a variable instead of a string.
I wrapped the address in quotes and redeployed the function. The CloudWatch error identified the exact line, which made the correction straightforward.
The form was blocked because the OPTIONS request did not receive a successful response before the browser attempted the POST request.
I added explicit OPTIONS handling and verified the API Gateway CORS configuration. The browser then allowed the POST request to continue.
CORS headers existed in both Lambda responses and API Gateway, which produced inconsistent behaviour and made troubleshooting harder.
I established one source of truth for the CORS policy at the API layer and kept the application response focused on status and data.
SES rejected confirmation messages to recruiter addresses because those recipients were not verified identities in the sandbox.
I completed domain authentication and requested production access. After approval, the workflow could send confirmations to normal recruiter addresses.
The SES console did not allow a production request before the domain identity had completed verification.
I finished the DKIM verification for pavankrishna.dev. The production request became available after SES recognised the verified domain.
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.
Reliable delivery depends on verified identities, DKIM, DMARC, account access and correct sender configuration.
The Lambda logs exposed concrete Python and SES errors, which was more effective than guessing from a generic frontend failure.
The browser must approve the CORS policy through OPTIONS before it sends the actual cross-origin form request.
The SES sandbox encourages domain verification and responsible sending practices before broader delivery is enabled.
A confirmation from the portfolio domain creates a more coherent experience than a message from an unrelated sender address.
Managing CORS in one layer prevents conflicting headers and makes the request flow easier to understand and maintain.