Back to Home

Serverless Contact Form

Build a secure, serverless contact form on AWS using SES, Lambda, and HTTP API Gateway, integrated into a dark-themed responsive frontend and proxied through CloudFront.

1. Amazon SES Identity Verification

In the AWS SES Sandbox environment, both sender and recipient email addresses must be explicitly verified before sending messages.

aws ses verify-email-identity \
  --email-address your-email@example.com \
  --region us-west-2

2. IAM Permissions Policy for Amazon SES

Grant the Lambda execution role authorization to dispatch emails via Amazon SES.

cat <<EOF > lambda-ses-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:SendRawEmail"
      ],
      "Resource": "*"
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name visitor-counter-lambda-role \
  --policy-name LambdaSESAccess \
  --policy-document file://lambda-ses-policy.json

3. Lambda Contact Handler Code

This Python function handles preflight CORS OPTIONS requests, validates payload fields, and sends formatted email notifications via boto3.

cat << 'EOF' > contact_function.py
import json
import boto3

SENDER_EMAIL = "your-email@example.com"
RECIPIENT_EMAIL = "your-email@example.com"

ses_client = boto3.client('ses', region_name='us-west-2')

def lambda_handler(event, context):
    cors_headers = {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key',
        'Access-Control-Allow-Methods': 'POST,OPTIONS'
    }

    # Handle CORS preflight OPTIONS request
    if event.get('requestContext', {}).get('http', {}).get('method') == 'OPTIONS':
        return {'statusCode': 200, 'headers': cors_headers, 'body': ''}

    try:
        body = json.loads(event.get('body', '{}'))
        name = body.get('name', '').strip()
        sender = body.get('email', '').strip()
        message = body.get('message', '').strip()

        if not name or not sender or not message:
            return {
                'statusCode': 400,
                'headers': cors_headers,
                'body': json.dumps({'error': 'Missing required fields.'})
            }

        subject = f"New Portfolio Contact from {name}"
        body_text = f"Name: {name}\nEmail: {sender}\n\nMessage:\n{message}"

        ses_client.send_email(
            Source=SENDER_EMAIL,
            Destination={'ToAddresses': [RECIPIENT_EMAIL]},
            Message={
                'Subject': {'Data': subject},
                'Body': {'Text': {'Data': body_text}}
            },
            ReplyToAddresses=[sender]
        )

        return {
            'statusCode': 200,
            'headers': cors_headers,
            'body': json.dumps({'message': 'Message sent successfully!'})
        }

    except Exception as e:
        return {
            'statusCode': 500,
            'headers': cors_headers,
            'body': json.dumps({'error': 'Internal server error', 'details': str(e)})
        }
EOF

zip contact_function.zip contact_function.py

ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)

aws lambda create-function \
  --function-name send-contact-email \
  --runtime python3.12 \
  --role arn:aws:iam::${ACCOUNT_ID}:role/visitor-counter-lambda-role \
  --handler contact_function.lambda_handler \
  --zip-file fileb://contact_function.zip \
  --timeout 10 \
  --region us-west-2

4. HTTP API Gateway Route & Integration

Expose the function under the POST /api/contact path on your existing HTTP API Gateway instance.

# 1. Create API Integration
INTEGRATION_ID=$(aws apigatewayv2 create-integration \
    --api-id oasi55hqkb \
    --integration-type AWS_PROXY \
    --integration-uri arn:aws:lambda:us-west-2:${ACCOUNT_ID}:function:send-contact-email \
    --payload-format-version 2.0 \
    --region us-west-2 \
    --query "IntegrationId" --output text)

# 2. Add POST /api/contact Route
aws apigatewayv2 create-route \
    --api-id oasi55hqkb \
    --route-key 'POST /api/contact' \
    --target "integrations/${INTEGRATION_ID}" \
    --region us-west-2

# 3. Grant Invoke Permission
aws lambda add-permission \
    --function-name send-contact-email \
    --statement-id apigateway-contact-perm \
    --action lambda:InvokeFunction \
    --principal apigateway.amazonaws.com \
    --source-arn "arn:aws:execute-api:us-west-2:${ACCOUNT_ID}:oasi55hqkb/*/*" \
    --region us-west-2

5. Frontend HTML Structure & CSS Styling

Add the styled container to your site markup and extend your stylesheet for focus states, input fields, and status feedback colors.

HTML Form Markup:

<div class="contact-form-container">
  <form id="contact-form">
    <div class="form-field">
      <label for="name">Name</label>
      <input type="text" id="name" placeholder="John Doe" required />
    </div>

    <div class="form-field">
      <label for="email">Email</label>
      <input type="email" id="email" placeholder="john@example.com" required />
    </div>

    <div class="form-field">
      <label for="message">Message</label>
      <textarea id="message" placeholder="Write your message here..." required></textarea>
    </div>

    <button type="submit" id="submit-btn">Send Message</button>
    <p id="form-status"></p>
  </form>
</div>

CSS Styling (styles.css / blog.css):

.contact-form-container {
  max-width: 600px;
  margin: 2rem 0;
  padding: 2rem;
  background-color: #0f172a;
  border: 1px solid #1e293b;
  border-radius: 12px;
}

#contact-form {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

.form-field {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.form-field label {
  font-family: 'Plus Jakarta Sans', sans-serif;
  font-size: 0.85rem;
  font-weight: 600;
  color: #94a3b8;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

#contact-form input,
#contact-form textarea {
  width: 100%;
  padding: 0.875rem 1rem;
  font-family: 'Plus Jakarta Sans', sans-serif;
  font-size: 0.95rem;
  color: #f8fafc;
  background-color: #1e293b;
  border: 1px solid #334155;
  border-radius: 8px;
  outline: none;
  transition: all 0.2s ease-in-out;
  box-sizing: border-box;
}

#contact-form input:focus,
#contact-form textarea:focus {
  border-color: #38bdf8;
  box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
  background-color: #0f172a;
}

#submit-btn {
  align-self: flex-start;
  padding: 0.875rem 1.75rem;
  font-family: 'Plus Jakarta Sans', sans-serif;
  font-weight: 600;
  font-size: 0.95rem;
  color: #0f172a;
  background-color: #38bdf8;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;
}

#submit-btn:hover {
  background-color: #7dd3fc;
  transform: translateY(-1px);
}

#submit-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

#form-status {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.875rem;
  margin-top: 0.5rem;
  min-height: 1.25rem;
}

#form-status.success { color: #4ade80; }
#form-status.error { color: #f87171; }

6. JavaScript Form Handler (`contact.js`)

Encapsulate form submission logic within a DOMContentLoaded wrapper to prevent execution delays on DOM elements.

document.addEventListener("DOMContentLoaded", () => {
  const form = document.getElementById("contact-form");
  if (!form) return;

  form.addEventListener("submit", async (e) => {
    e.preventDefault();

    const statusEl = document.getElementById("form-status");
    const submitBtn = document.getElementById("submit-btn");

    statusEl.className = "";
    statusEl.innerText = "Sending...";
    submitBtn.disabled = true;

    const payload = {
      name: document.getElementById("name").value,
      email: document.getElementById("email").value,
      message: document.getElementById("message").value
    };

    try {
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload)
      });

      const result = await response.json();

      if (response.ok) {
        statusEl.className = "success";
        statusEl.innerText = "Message sent successfully!";
        form.reset();
      } else {
        statusEl.className = "error";
        statusEl.innerText = result.error || "Failed to send message.";
      }
    } catch (err) {
      console.error("Contact Form Error:", err);
      statusEl.className = "error";
      statusEl.innerText = "Error sending message. Please try again.";
    } finally {
      submitBtn.disabled = false;
    }
  });
});

7. Deployment & Cache Invalidation

Deploy asset updates to S3 and invalidate CloudFront edge caches.

aws cloudfront create-invalidation \
  --distribution-id d1nrzvrxt6zac1 \
  --paths "/*"

8. Architectural Insights & Troubleshooting

  • CORS Preflight (OPTIONS): Browsers automatically send an OPTIONS request prior to cross-origin POST requests with JSON payloads. Lambda must handle this explicitly and return 200 OK with allowed headers.
  • CloudFront Path Alignment: Since CloudFront proxies the /api/* pattern to API Gateway, the API Gateway route must match the forwarded path (POST /api/contact).
  • DOM Ready Execution: When storing JavaScript in dedicated script files, wrap execution logic inside DOMContentLoaded or use the defer attribute to prevent target elements from evaluating to null.