41dbd77c2f
- Add professional HTML email template matching CV aesthetic - Implement multipart emails (HTML + plain text fallback) - Configure DreamHost SMTP with SSL (port 465) - Add "light only" color scheme for Gmail iOS compatibility - Include Reply-To header for easy sender response - Add email validation and integration tests - Update .env.example with DreamHost/Gmail SMTP examples - Add .env to .gitignore to protect credentials - Document email template customization and dark mode approach
596 lines
15 KiB
Markdown
596 lines
15 KiB
Markdown
# 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`
|
|
|
|
```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`
|
|
|
|
```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`
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Contact Form</title>
|
|
<!-- Include HTMX -->
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></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.,!?'"()\-:;#]+"
|
|
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)
|
|
|
|
```go
|
|
// 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:**
|
|
```go
|
|
mux.HandleFunc("/contact", contactHandler.ShowContactForm)
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Configure Email Service
|
|
|
|
### Option 1: DreamHost SMTP (Recommended)
|
|
|
|
**Environment variables:**
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
SENDGRID_API_KEY=your-api-key
|
|
CONTACT_EMAIL=contact@yourdomain.com
|
|
```
|
|
|
|
### Option 4: AWS SES
|
|
|
|
```bash
|
|
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
|
|
```bash
|
|
# 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
|
|
```bash
|
|
# 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
|
|
```bash
|
|
# 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
|
|
```bash
|
|
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
|
|
```nginx
|
|
# /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
|
|
```bash
|
|
# 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](https://www.hteumeuleu.com/2021/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`:
|
|
|
|
```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.**
|