Serverless contact + booking backend for waterapps.com.au. Receives enquiries and discovery-call booking requests, then sends notifications via AWS SES.
┌─────────────────────┐ ┌──────────────────┐ ┌────────────┐ ┌─────────┐
│ │ POST │ │ invoke │ │ send │ │
│ GitHub Pages │────────▶│ API Gateway │────────▶│ Lambda │────────▶│ SES │
│ waterapps.com.au │ /contact,/booking,/availability │ Node.js 22│ │ Email │
│ │◀────────│ (CORS locked) │◀────────│ │ │ │
│ │ JSON │ │ JSON │ │ │ │
└─────────────────────┘ └──────────────────┘ └─────┬──────┘ └────┬────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ CloudWatch │ │ Inbox │
│ Logs │ │ hello@ │
└────────────┘ └────────────┘
| Resource | Monthly Cost | Notes |
|---|---|---|
| Lambda | $0.00 | 1M free requests/month — contact forms won't exceed 1K |
| API Gateway | $0.00 | 1M free HTTP API requests/month |
| SES | $0.00 | First 62K emails free when sent from Lambda |
| CloudWatch Logs | $0.00 | <1MB/month at contact form volume |
| Total | $0.00 | Within AWS free tier for typical usage |
- AWS account with CLI configured (
aws configure) - Terraform >= 1.5
- Node.js 18+ (for Lambda dependencies)
- SES: verify your sender email (Terraform handles this — check inbox after first apply)
git clone https://github.com/vkaushik13/waterapps-contact-form.git
cd waterapps-contact-form
# Set your email addresses
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
# Edit terraform.tfvars with your valuescd lambda
npm install
cd ..cd terraform
terraform init
terraform plan # Review what will be created
terraform apply # DeployMigration safety note:
preserve_legacy_reviews_stack = true(default) keeps legacy/reviewsAPI/auth/IAM resources managed so booking rollout does not destroy existing reviews infrastructure.- Set it to
falseonly after an explicit reviews retirement plan is approved.
After first terraform apply, AWS sends a verification email to your source_email. Click the link to activate sending.
terraform output api_endpoint
# Example: https://abc123.execute-api.ap-southeast-2.amazonaws.com/contact
terraform output booking_endpoint
# Example: https://abc123.execute-api.ap-southeast-2.amazonaws.com/booking
terraform output availability_endpoint
# Example: https://abc123.execute-api.ap-southeast-2.amazonaws.com/availabilityAdd the API endpoint to your contact form's fetch() call.
Recommended frontend behavior:
- Surface
fieldErrorsfrom API responses
Example:
const response = await fetch("YOUR_API_ENDPOINT_HERE", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Jane Smith",
email: "jane@example.com",
company: "Acme Corp", // optional
phone: "+61 400 000 000", // optional
message: "I'd like to discuss a DevOps engagement..."
})
});
const data = await response.json();
// data.status === "success" || "error"
// data.fieldErrors may be returned on validation failureBooking endpoints:
GET /availability?days=7returns UTC booking slotsPOST /bookingacceptsname,email, optionalcompany,notes,timezone, andslotStart(UTC ISO timestamp)
# Replace with your actual endpoint
curl -X POST https://YOUR-API-ID.execute-api.ap-southeast-2.amazonaws.com/contact \
-H "Content-Type: application/json" \
-H "Origin: https://www.waterapps.com.au" \
-d '{"name":"Test","email":"test@example.com","message":"Testing the contact form from terminal"}'
# Health/smoke endpoint (frontend and pipeline friendly)
curl https://YOUR-API-ID.execute-api.ap-southeast-2.amazonaws.com/healthNote: POST /contact rejects requests without an Origin header (403 origin_required) to reduce abuse from non-browser clients.
Repeatable smoke test script (recommended after deploys / config changes):
./scripts/smoke-test.sh \
--endpoint https://YOUR-API-ID.execute-api.ap-southeast-2.amazonaws.com/contactSee /Users/varunau/Projects/waterapps/waterapps-contact-form/docs/smoke-test-runbook.md for the full runbook and failure triage.
Current capability:
- Availability API for upcoming slots (
GET /availability) - Booking request API (
POST /booking) - Email notification to
target_emailfor each booking request - Same-origin CORS and server-side validation
Current limitation:
- No calendar OAuth sync (Google/Microsoft) yet
- Request-based booking flow (slot confirmation is finalized by email)
waterapps-contact-form/
├── lambda/
│ ├── index.mjs # Routes: /contact, /availability, /booking, /health
│ └── package.json # Lambda dependencies
├── terraform/
│ ├── main.tf # Lambda, API GW, IAM, SES, CloudWatch
│ ├── backend.tf # Remote state backend declaration (S3)
│ ├── variables.tf # All configurable with validation
│ ├── outputs.tf # API endpoint + useful references
│ └── terraform.tfvars.example
├── .github/
│ └── workflows/
│ └── deploy.yml # Validate on PR, deploy on push to main
├── docs/
│ ├── smoke-test-runbook.md # Post-deploy API smoke test procedure
│ └── adr/
│ └── 001-serverless-contact-form.md
├── scripts/
│ └── smoke-test.sh # Repeatable health/validation/origin smoke test
├── CLAUDE.md # Engineering standards for Claude Code
├── CHANGELOG.md
└── README.md
- CORS: Configurable allowlist in Terraform and enforced in Lambda response handling
- IAM: Lambda role has only
ses:SendEmail(scoped to verified identity) and CloudWatch logging - No
Resource: "*": Every IAM permission is scoped to specific ARNs - Input validation: Name, email, message validated server-side
- Field limits: Request size and input lengths constrained to reduce abuse
- HTML sanitisation: All input escaped before use
- Anti-spam: URL limit and spam-pattern checks in backend validation
- Booking guardrails: slot-window, lead-time, and UTC timestamp checks
- No secrets in code: Emails passed via environment variables, AWS auth via OIDC
New AWS accounts start in SES sandbox mode. This means you can only send to verified email addresses. For a contact form where you're sending to yourself, this is fine. If you later need to send confirmation emails to the submitter, request production SES access in the AWS console.
To avoid warnings like "sender can't be verified", deploy SES with:
- Domain identity (
source_email_domain, default:waterapps.com.au) - Easy DKIM (
aws_ses_domain_dkim) - Custom MAIL FROM subdomain (
aws_ses_domain_mail_from, default:mail.waterapps.com.au) - Sender address from that domain (
source_email, recommended:bookings@waterapps.com.au)
This repo now manages all SES auth resources in Terraform (enabled by default with manage_ses_domain_authentication = true).
After terraform apply, publish values from outputs:
- Domain verification TXT:
_amazonses.<source_email_domain>=ses_domain_identity_verification_token
- DKIM CNAME (3 records):
<token>._domainkey.<source_email_domain>-><token>.dkim.amazonses.com- tokens are in output
ses_dkim_tokens
- MAIL FROM MX:
<mail_from_subdomain>.<source_email_domain>MX10 feedback-smtp.<region>.amazonses.com- output:
ses_mail_from_mx_record
- MAIL FROM SPF TXT:
<mail_from_subdomain>.<source_email_domain>TXTv=spf1 include:amazonses.com -all- output:
ses_mail_from_spf_txt
- DMARC (domain-level):
_dmarc.<source_email_domain>should remain present and enforced
- Send a booking test email from
/booking. - In Gmail "Show original", confirm:
dkim=passforwaterapps.com.auspf=passdmarc=pass
- Confirm warning banner is no longer shown after provider caches refresh.
The GitHub Actions workflow uses OIDC federation — no long-lived AWS keys stored in GitHub.
Current flow:
- Push / PR: validate + security checks
- PR (optional): Terraform plan when remote-state and OIDC prerequisites are configured
mainpush: deploy only when auto deploy is explicitly enabled- Manual
workflow_dispatch: Terraform plan/apply withproductionenvironment approval
To enable:
- Create an IAM OIDC identity provider for GitHub in your AWS account
- Create a deploy role with permissions for Lambda, API GW, SES, IAM, CloudWatch
- Add
AWS_DEPLOY_ROLE_ARNto your GitHub repository secrets - Configure remote Terraform state + locking (required for safe CI apply):
- Repo vars:
CONTACT_FORM_TF_STATE_BUCKETCONTACT_FORM_TF_STATE_KEY(optional override; default:contact-form/terraform.tfstate)CONTACT_FORM_TF_LOCK_TABLE
- Repo vars:
- Set
CONTACT_FORM_AUTO_DEPLOY_ENABLED=trueonly after the backend is configured - Push to
main— the workflow handles the rest
Without remote state, CI runners use ephemeral local state and can collide with existing AWS resources.
For GitOps-style approvals, configure:
- Branch protection on
main(require PR + status checks) - GitHub
productionenvironment required reviewers (for deploy approval)
Use the workflow Contact Form — Deploy with workflow_dispatch input:
action=planfor a safe manual plan runaction=applyfor a manual apply (gated byproductionenvironment approval)
This is the recommended operator path when auto deploy is disabled.
# Watch Lambda logs
aws logs tail /aws/lambda/waterapps-prod-contact --follow
# Check health endpoint
curl "$(terraform output -raw health_endpoint)"
# Check recent invocations
aws lambda get-function --function-name waterapps-prod-contact --query 'Configuration.LastModified'cd terraform
terraform destroyWater Apps Pty Ltd | ABN 63 632 823 084 | Sydney, Australia