Files
cv-site/doc/17-CONTACT-FORM.md
juanatsap 8f4d0e9433 feat: self-host HTMX 2.0.10 and Hyperscript 0.9.91, remove unpkg CDN
- Download htmx.min.js v2.0.10 and _hyperscript.min.js v0.9.91 locally
- Update head-scripts.html to load from /static/ instead of unpkg CDN
- Remove https://unpkg.com from CSP script-src whitelist
- Update all documentation references to reflect self-hosted paths
- No breaking changes: all hx-* attributes are HTMX 2.0 compatible
2026-05-14 12:59:30 +01:00

15 KiB

Contact Form Quick Start Guide

TL;DR

All security middleware is implemented and tested. You just need to:

  1. Create the contact handler
  2. Integrate an email service
  3. Add the route
  4. Create the HTML form

Step 1: Create Contact Handler

File: internal/handlers/contact.go

package handlers

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/juanatsap/cv-site/internal/middleware"
	"github.com/juanatsap/cv-site/internal/validation"
)

type ContactHandler struct {
	// Add email service here when you choose one
	// emailService EmailService
}

func NewContactHandler() *ContactHandler {
	return &ContactHandler{}
}

// SendMessage handles contact form submissions
func (h *ContactHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
	// 1. Parse JSON request
	var req validation.ContactFormRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		middleware.LogSecurityEvent(middleware.EventValidationFailed, r, "Invalid JSON: "+err.Error())
		http.Error(w, "Invalid request format", http.StatusBadRequest)
		return
	}

	// 2. Set server timestamp (don't trust client)
	req.Timestamp = time.Now().Unix()

	// 3. Validate input
	if err := validation.ValidateContactForm(&req); err != nil {
		middleware.LogSecurityEvent(middleware.EventValidationFailed, r, err.Error())

		// Return user-friendly error for HTMX
		if r.Header.Get("HX-Request") != "" {
			w.Header().Set("Content-Type", "text/html")
			w.WriteHeader(http.StatusBadRequest)
			fmt.Fprintf(w, `<div class="error">%s</div>`, err.Error())
			return
		}

		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// 4. Sanitize content (removes HTML, normalizes whitespace)
	validation.SanitizeContactForm(&req)

	// 5. Send email
	if err := h.sendEmail(&req); err != nil {
		middleware.LogSecurityEvent(middleware.EventEmailSendFailed, r, err.Error())
		http.Error(w, "Failed to send message. Please try again later.", http.StatusInternalServerError)
		return
	}

	// 6. Log success
	middleware.LogSecurityEvent(middleware.EventContactFormSent, r,
		fmt.Sprintf("From: %s <%s>", req.Name, req.Email))

	// 7. Return success
	if r.Header.Get("HX-Request") != "" {
		w.Header().Set("Content-Type", "text/html")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`<div class="success">Message sent successfully! We'll get back to you soon.</div>`))
		return
	}

	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{
		"message": "Message sent successfully",
	})
}

// sendEmail sends the contact form email
// TODO: Choose an email service and implement this
func (h *ContactHandler) sendEmail(req *validation.ContactFormRequest) error {
	// OPTION 1: SMTP (using net/smtp)
	// return h.sendViaSMTP(req)

	// OPTION 2: SendGrid API
	// return h.sendViaSendGrid(req)

	// OPTION 3: AWS SES
	// return h.sendViaAWSSES(req)

	// OPTION 4: Mailgun API
	// return h.sendViaMailgun(req)

	// For now, just log it (replace with actual implementation)
	log.Printf("EMAIL: From: %s <%s>, Subject: %s\n%s",
		req.Name, req.Email, req.Subject, req.Message)

	return nil
}

// Example SMTP implementation
/*
import "net/smtp"

func (h *ContactHandler) sendViaSMTP(req *validation.ContactFormRequest) error {
	// Load SMTP config from environment
	host := os.Getenv("SMTP_HOST")
	port := os.Getenv("SMTP_PORT")
	user := os.Getenv("SMTP_USER")
	pass := os.Getenv("SMTP_PASS")
	from := os.Getenv("SMTP_FROM")
	to := os.Getenv("CONTACT_EMAIL")

	// Set up authentication
	auth := smtp.PlainAuth("", user, pass, host)

	// Build email
	subject := "Contact Form: " + req.Subject
	body := fmt.Sprintf(`From: %s <%s>
Company: %s

%s

---
Sent via contact form on %s
`, req.Name, req.Email, req.Company, req.Message, time.Now().Format("2006-01-02 15:04:05"))

	msg := []byte(fmt.Sprintf(`To: %s
From: %s
Reply-To: %s
Subject: %s
Content-Type: text/plain; charset=UTF-8

%s`, to, from, req.Email, subject, body))

	// Send email
	return smtp.SendMail(host+":"+port, auth, from, []string{to}, msg)
}
*/

Step 2: Add Route

File: internal/routes/routes.go

func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
	mux := http.NewServeMux()

	// ... existing routes ...

	// Contact form endpoint - FULLY PROTECTED
	contactHandler := handlers.NewContactHandler()
	csrf := middleware.NewCSRFProtection()
	contactRateLimiter := middleware.NewContactRateLimiter()

	protectedContactHandler := middleware.BrowserOnly(
		csrf.Middleware(
			contactRateLimiter.Middleware(
				http.HandlerFunc(contactHandler.SendMessage),
			),
		),
	)

	mux.Handle("/api/contact", protectedContactHandler)

	// ... rest of middleware chain ...

	return handler
}

Step 3: Create HTML Form Template

File: templates/contact.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Contact Form</title>
    <!-- Include HTMX -->
    <script src="/static/htmx/htmx.min.js"></script>
    <style>
        .form-group { margin-bottom: 1rem; }
        label { display: block; margin-bottom: 0.5rem; }
        input, textarea { width: 100%; padding: 0.5rem; }
        button { padding: 0.75rem 1.5rem; background: #0066cc; color: white; border: none; cursor: pointer; }
        .error { color: red; padding: 1rem; background: #ffeeee; margin: 1rem 0; }
        .success { color: green; padding: 1rem; background: #eeffee; margin: 1rem 0; }
        .hidden { position: absolute; left: -9999px; }
    </style>
</head>
<body>
    <h1>Contact Me</h1>

    <form id="contact-form"
          hx-post="/api/contact"
          hx-trigger="submit"
          hx-target="#form-result"
          hx-swap="innerHTML"
          _="on htmx:afterRequest if event.detail.successful reset() me end">

        <!-- CSRF Token (get from server) -->
        <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">

        <!-- Timestamp (set by JavaScript) -->
        <input type="hidden" name="timestamp" id="form-timestamp">

        <!-- Honeypot field (hidden from humans, visible to bots) -->
        <input type="text"
               name="website"
               id="website"
               class="hidden"
               tabindex="-1"
               autocomplete="off"
               aria-hidden="true">

        <!-- Real Fields -->
        <div class="form-group">
            <label for="name">Name *</label>
            <input type="text"
                   name="name"
                   id="name"
                   required
                   maxlength="100"
                   pattern="[\p{L}\s'\-]+"
                   title="Name can only contain letters, spaces, hyphens, and apostrophes">
        </div>

        <div class="form-group">
            <label for="email">Email *</label>
            <input type="email"
                   name="email"
                   id="email"
                   required
                   maxlength="254">
        </div>

        <div class="form-group">
            <label for="company">Company</label>
            <input type="text"
                   name="company"
                   id="company"
                   maxlength="100">
        </div>

        <div class="form-group">
            <label for="subject">Subject *</label>
            <input type="text"
                   name="subject"
                   id="subject"
                   required
                   maxlength="200"
                   pattern="[\p{L}\p{N}\s.,!?'&quot;()\-:;#]+"
                   title="Subject can only contain letters, numbers, spaces, and basic punctuation">
        </div>

        <div class="form-group">
            <label for="message">Message *</label>
            <textarea name="message"
                      id="message"
                      required
                      maxlength="5000"
                      rows="6"></textarea>
        </div>

        <button type="submit">Send Message</button>
    </form>

    <div id="form-result"></div>

    <script>
    // Set timestamp when form loads (for bot detection)
    document.addEventListener('DOMContentLoaded', function() {
        document.getElementById('form-timestamp').value = Math.floor(Date.now() / 1000);
    });
    </script>
</body>
</html>

Step 4: Generate CSRF Token in Handler

File: internal/handlers/contact.go (add page handler)

// ShowContactForm displays the contact form with CSRF token
func (h *ContactHandler) ShowContactForm(w http.ResponseWriter, r *http.Request) {
	// Get or generate CSRF token
	csrf := middleware.NewCSRFProtection()
	token, err := csrf.GetToken(w, r)
	if err != nil {
		http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError)
		return
	}

	// Render template with CSRF token
	data := map[string]interface{}{
		"CSRFToken": token,
	}

	// Use your template manager to render
	// h.templates.Render(w, "contact.html", data)
}

Add route:

mux.HandleFunc("/contact", contactHandler.ShowContactForm)

Step 5: Configure Email Service

Environment variables:

# DreamHost uses port 465 with SSL (implicit TLS)
SMTP_HOST=smtp.dreamhost.com
SMTP_PORT=465
SMTP_USER=your-email@yourdomain.com
SMTP_PASSWORD=your-email-password
SMTP_FROM_EMAIL=your-email@yourdomain.com
CONTACT_EMAIL=recipient@example.com

Option 2: Gmail SMTP

Environment variables:

# Gmail uses port 587 with STARTTLS
# Requires App Password (enable 2FA first)
# https://myaccount.google.com/apppasswords
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
SMTP_FROM_EMAIL=your-email@gmail.com
CONTACT_EMAIL=recipient@example.com

Port Reference

Port Protocol Description
465 SSL/TLS Implicit TLS - direct encrypted connection
587 STARTTLS Plain connection upgraded to TLS

Option 3: SendGrid

SENDGRID_API_KEY=your-api-key
CONTACT_EMAIL=contact@yourdomain.com

Option 4: AWS SES

AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
CONTACT_EMAIL=contact@yourdomain.com

Testing Checklist

1. Manual Testing

# Test valid submission (browser required)
# Fill out form on http://localhost:1999/contact

# Test CSRF protection
curl -X POST http://localhost:1999/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"test@example.com","subject":"Test","message":"Test"}'
# Expected: 403 Forbidden (missing CSRF token or browser headers)

# Test rate limiting (submit 6 times within an hour)
# Expected: 6th submission returns 429 Too Many Requests

# Test bot detection - honeypot
# Fill the hidden "website" field
# Expected: Validation error

# Test bot detection - timing
# Submit form immediately after page load
# Expected: Validation error

# Test email injection
# Try: name="Test\nBcc: attacker@evil.com"
# Expected: Validation error

2. Attack Simulations

# SQL Injection
curl -X POST http://localhost:1999/api/contact \
  -H "Origin: http://localhost:1999" \
  -H "X-Requested-With: XMLHttpRequest" \
  -H "Cookie: csrf_token=..." \
  -d '{"name":"Robert\"; DROP TABLE users; --","email":"test@example.com",...}'
# Expected: 400 Bad Request (invalid name format)

# XSS
# Message: "<script>alert('XSS')</script>"
# Expected: HTML escaped in email

# Email Header Injection
# Subject: "Test\nBcc: attacker@evil.com"
# Expected: 400 Bad Request (invalid characters)

Security Monitoring

Check Logs

# View security events
tail -f /var/log/cv-app/security.log

# Filter by severity
tail -f /var/log/cv-app/security.log | jq 'select(.severity == "HIGH")'

# Count blocked requests
grep "BLOCKED" /var/log/cv-app/security.log | wc -l

# See who's trying to attack
grep "BLOCKED" /var/log/cv-app/security.log | jq -r '.ip' | sort | uniq -c | sort -rn

Troubleshooting

"CSRF validation failed"

  • Make sure CSRF token is being generated and included in form
  • Check cookie is being set with correct domain
  • Verify token in cookie matches token in form

"Forbidden: Browser access only"

  • Ensure Origin or Referer header is present
  • Check ALLOWED_ORIGINS environment variable
  • Verify X-Requested-With header is set by HTMX

"Rate limit exceeded"

  • Wait 1 hour and try again
  • Check if IP is correctly extracted (X-Forwarded-For)
  • Verify rate limit configuration (5 per hour)

"Bot detected"

  • Don't fill the honeypot field (id="website")
  • Wait at least 2 seconds before submitting
  • Ensure timestamp is set correctly

Production Deployment

1. Set Environment Variables

GO_ENV=production
ALLOWED_ORIGINS=juan.andres.morenorub.io

# DreamHost SMTP Configuration
SMTP_HOST=smtp.dreamhost.com
SMTP_PORT=465
SMTP_USER=info@drolosoft.com
SMTP_PASSWORD=your-password
SMTP_FROM_EMAIL=info@drolosoft.com
CONTACT_EMAIL=your-personal-email@example.com

2. Configure Nginx Rate Limiting

# /etc/nginx/sites-available/cv-app
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/h;

location /api/contact {
    limit_req zone=contact burst=1 nodelay;
    proxy_pass http://127.0.0.1:1999;
    # ... other proxy settings ...
}

3. Set Up Monitoring

# Configure fail2ban for repeated attacks
# See SECURITY-AUDIT-REPORT.md for details

# Set up log rotation
sudo vi /etc/logrotate.d/cv-app

# Configure alerts (Prometheus/Grafana)
# Monitor rate_limit_violations, csrf_violations, etc.


Email Templates

The contact form uses a professional HTML email template that matches the CV's aesthetic.

Features

  • Responsive design - Works on desktop, tablet, and mobile
  • Light-only color scheme - Forces consistent rendering across all email clients
  • Bracket aesthetic - { CV Contact } header matching CV design
  • Green accent color - #27ae60 consistent with CV highlights
  • Multipart format - Includes both HTML and plain text versions
  • Reply-To header - Automatically set to the sender's email

Dark Mode Compatibility

The template uses <meta name="color-scheme" content="light only"> to prevent email clients (especially Gmail iOS) from unpredictably inverting colors in dark mode.

Why not support dark mode?

  • Gmail iOS ignores CSS @media (prefers-color-scheme: dark) rules
  • It applies its own color inversion algorithm that breaks designs
  • Using "light only" ensures the email looks identical everywhere

Reference: How emails react to dark mode

Template Files

  • internal/services/email_theme.go - CSS theme and HTML template
  • internal/services/email.go - Email service with multipart support

Customization

To customize the email template, edit email_theme.go:

// Change accent color
color: #27ae60;  // Green - change to your brand color

// Change header text
<span class="bracket">{</span> CV Contact <span class="bracket">}</span>

// Modify footer link
<a href="https://your-domain.com" class="email-footer-link">your-domain.com</a>

That's It!

All security middleware is already implemented and tested:

  • CSRF protection
  • Origin validation (browser-only)
  • Input validation & sanitization
  • Rate limiting (5/hour)
  • Bot detection (honeypot + timing)
  • Email header injection prevention
  • Security logging

You just need to:

  1. Create the contact handler (copy code above)
  2. Choose and configure an email service
  3. Add the routes
  4. Create the HTML form

Your contact form is now production-ready with comprehensive security controls.