Implementing a Secure Contact Form with XSS Prevention and Spam Protection
Introduction
Section titled “Introduction”Contact forms are essential for user communication, but they’re also a common attack vector. How do you prevent spam submissions? How do you protect against XSS attacks? How do you ensure legitimate messages aren’t lost in a flood of bot traffic?
In this article, I’ll show you how to build a secure, production-ready contact form with multiple layers of protection while maintaining excellent user experience.
Database Schema
Section titled “Database Schema”Let’s design a table to store contact messages:
from datetime import datetimefrom portfolio_app import db
class ContactMessage(db.Model): __tablename__ = "tbl_contact_messages"
ccn_contact_message = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) email = db.Column(db.String(100), nullable=False) subject = db.Column(db.String(200), nullable=False) message = db.Column(db.Text, nullable=False)
# Status tracking read = db.Column(db.Boolean, default=False) replied = db.Column(db.Boolean, default=False)
# Anti-spam metadata ip_address = db.Column(db.String(45)) user_agent = db.Column(db.String(500))
# Timestamps created_at = db.Column(db.DateTime, default=datetime.now, nullable=False, index=True) read_at = db.Column(db.DateTime)
def to_dict(self): return { "ccn_contact_message": self.ccn_contact_message, "name": self.name, "email": self.email, "subject": self.subject, "message": self.message, "read": self.read, "replied": self.replied, "created_at": self.created_at.isoformat(), "read_at": self.read_at.isoformat() if self.read_at else None }
def mark_as_read(self): """Mark message as read""" if not self.read: self.read = True self.read_at = datetime.now() db.session.commit()
def __repr__(self): return f"ContactMessage(from='{self.email}', subject='{self.subject}')"Input Validation Schema
Section titled “Input Validation Schema”Create comprehensive validation with Marshmallow:
from marshmallow import Schema, fields, validates, ValidationErrorimport re
class ContactFormSchema(Schema): name = fields.Str( required=True, validate=lambda x: 1 <= len(x.strip()) <= 100, error_messages={'required': 'Name is required'} ) email = fields.Email( required=True, error_messages={'required': 'Email is required', 'invalid': 'Invalid email format'} ) subject = fields.Str( required=True, validate=lambda x: 1 <= len(x.strip()) <= 200, error_messages={'required': 'Subject is required'} ) message = fields.Str( required=True, validate=lambda x: 10 <= len(x.strip()) <= 2000, error_messages={'required': 'Message is required'} )
# Honeypot field (should be empty) website = fields.Str(allow_none=True)
@validates('name') def validate_name(self, value): """Ensure name doesn't contain suspicious patterns""" if re.search(r'<|>|script|onclick|onerror', value, re.IGNORECASE): raise ValidationError('Invalid characters in name') if len(value.strip()) < 2: raise ValidationError('Name too short')
@validates('subject') def validate_subject(self, value): """Validate subject field""" if re.search(r'<|>|script', value, re.IGNORECASE): raise ValidationError('Invalid characters in subject')
@validates('message') def validate_message(self, value): """Validate message content""" # Check minimum length if len(value.strip()) < 10: raise ValidationError('Message too short (minimum 10 characters)')
# Check for excessive links (spam indicator) link_count = len(re.findall(r'http[s]?://', value)) if link_count > 3: raise ValidationError('Too many links in message')API Endpoint with Security Measures
Section titled “API Endpoint with Security Measures”from flask import request, jsonify, Blueprintfrom flask_jwt_extended import jwt_requiredfrom marshmallow import ValidationErrorfrom datetime import datetime, timedelta
from portfolio_app.schemas.schema_contact import ContactFormSchemafrom portfolio_app.models.tbl_contact_messages import ContactMessagefrom portfolio_app.extensions import db
blueprint_api_contact = Blueprint("api_contact", __name__)
# Simple in-memory rate limiting (use Redis in production)submission_tracker = {}
def check_rate_limit(ip_address, limit=5, window=3600): """Check if IP has exceeded submission limit""" now = datetime.now()
# Clean old entries submission_tracker[ip_address] = [ timestamp for timestamp in submission_tracker.get(ip_address, []) if now - timestamp < timedelta(seconds=window) ]
# Check limit if len(submission_tracker.get(ip_address, [])) >= limit: return False
# Track submission if ip_address not in submission_tracker: submission_tracker[ip_address] = [] submission_tracker[ip_address].append(now)
return True
@blueprint_api_contact.route("/api/v1/contact", methods=["POST"])def submit_contact_form(): """Handle contact form submission (public endpoint)""" try: # Get client IP ip_address = request.remote_addr
# Rate limiting if not check_rate_limit(ip_address): return { "error": "Too many submissions. Please try again later." }, 429
# Validate input schema = ContactFormSchema() data = schema.load(request.json)
# Honeypot check (website field should be empty) if data.get("website"): # Log as spam but return success to avoid detection print(f"🚫 Spam detected from {ip_address}") return { "message": "Thank you for your message!", "status": "success" }, 200
# Time-based validation (if submission_time is tracked) # Forms submitted too quickly are likely bots
# Sanitize input (additional layer) name = data["name"].strip() email = data["email"].strip().lower() subject = data["subject"].strip() message = data["message"].strip()
# Create contact message contact_message = ContactMessage( name=name, email=email, subject=subject, message=message, ip_address=ip_address, user_agent=request.headers.get('User-Agent', '') )
db.session.add(contact_message) db.session.commit()
print(f"📧 New contact message from {name} ({email})")
# Optional: Send notification email to admin # send_admin_notification(contact_message)
return { "message": "Thank you for your message! I'll get back to you soon.", "status": "success" }, 200
except ValidationError as e: return { "error": "Validation error", "details": e.messages }, 400 except Exception as e: db.session.rollback() print(f"❌ Error processing contact form: {str(e)}") return { "error": "An error occurred. Please try again later." }, 500
@blueprint_api_contact.route("/api/v1/contact", methods=["GET"])@jwt_required()def get_contact_messages(): """Get all contact messages (admin only)""" try: # Pagination parameters page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 10, type=int) read_filter = request.args.get("read") # 'true', 'false', or None
# Build query query = ContactMessage.query
# Apply filters if read_filter == 'true': query = query.filter_by(read=True) elif read_filter == 'false': query = query.filter_by(read=False)
# Paginate pagination = query.order_by( ContactMessage.created_at.desc() ).paginate(page=page, per_page=per_page, error_out=False)
return { "messages": [msg.to_dict() for msg in pagination.items], "pagination": { "page": page, "per_page": per_page, "total": pagination.total, "pages": pagination.pages, "has_next": pagination.has_next, "has_prev": pagination.has_prev } }, 200
except Exception as e: return {"error": f"Error fetching messages: {str(e)}"}, 500
@blueprint_api_contact.route("/api/v1/contact/<int:message_id>/read", methods=["PUT"])@jwt_required()def mark_message_as_read(message_id): """Mark a contact message as read""" try: message = ContactMessage.query.get_or_404(message_id) message.mark_as_read()
return { "message": "Message marked as read", "data": message.to_dict() }, 200
except Exception as e: db.session.rollback() return {"error": f"Error updating message: {str(e)}"}, 500Frontend: Contact Form Component
Section titled “Frontend: Contact Form Component”Build a user-friendly contact form:
import { useState } from 'react';import { useForm } from 'react-hook-form';import { toast } from 'react-toastify';import { motion } from 'framer-motion';
function Contact() { const [loading, setLoading] = useState(false); const [submitted, setSubmitted] = useState(false); const { register, handleSubmit, formState: { errors }, reset } = useForm();
const onSubmit = async (data) => { setLoading(true); try { const response = await fetch('/api/v1/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
if (response.ok) { setSubmitted(true); reset(); toast.success('Message sent successfully!');
// Reset submitted state after 5 seconds setTimeout(() => setSubmitted(false), 5000); } else { const error = await response.json(); toast.error(error.error || 'Failed to send message'); } } catch (error) { toast.error('Network error. Please try again.'); } finally { setLoading(false); } };
return ( <div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 py-12 px-4"> <div className="max-w-4xl mx-auto"> {/* Header */} <div className="text-center mb-12"> <h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4"> Get In Touch </h1> <p className="text-xl text-gray-600"> Have a question or want to work together? I'd love to hear from you! </p> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> {/* Contact Information */} <div className="space-y-6"> <div className="bg-white p-6 rounded-lg shadow-lg"> <h3 className="text-xl font-semibold mb-4">Contact Information</h3>
<div className="space-y-4"> <div className="flex items-start"> <span className="text-2xl mr-4">📧</span> <div> <div className="font-medium">Email</div> <a href="mailto:ruizdev7@outlook.com" className="text-blue-600 hover:text-blue-800"> ruizdev7@outlook.com </a> </div> </div>
<div className="flex items-start"> <span className="text-2xl mr-4">💼</span> <div> <div className="font-medium">LinkedIn</div> <a href="https://www.linkedin.com/in/Ruizdev7" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800" > linkedin.com/in/Ruizdev7 </a> </div> </div>
<div className="flex items-start"> <span className="text-2xl mr-4">🐙</span> <div> <div className="font-medium">GitHub</div> <a href="https://github.com/ruizdev7" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800" > github.com/ruizdev7 </a> </div> </div> </div> </div>
<div className="bg-white p-6 rounded-lg shadow-lg"> <h3 className="text-xl font-semibold mb-4">Response Time</h3> <p className="text-gray-600"> I typically respond within 24-48 hours. For urgent inquiries, please reach out via LinkedIn. </p> </div> </div>
{/* Contact Form */} <div> {submitted ? ( <motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="bg-white p-8 rounded-lg shadow-lg text-center" > <div className="text-6xl mb-4">✅</div> <h3 className="text-2xl font-bold mb-2">Message Sent!</h3> <p className="text-gray-600"> Thank you for reaching out. I'll get back to you soon! </p> </motion.div> ) : ( <form onSubmit={handleSubmit(onSubmit)} className="bg-white p-8 rounded-lg shadow-lg"> <h3 className="text-2xl font-semibold mb-6">Send a Message</h3>
{/* Name Field */} <div className="mb-4"> <label className="block text-sm font-medium text-gray-700 mb-2"> Your Name * </label> <input {...register('name', { required: 'Name is required', minLength: { value: 2, message: 'Name too short' }, maxLength: { value: 100, message: 'Name too long' } })} className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="John Doe" /> {errors.name && ( <span className="text-red-500 text-sm mt-1">{errors.name.message}</span> )} </div>
{/* Email Field */} <div className="mb-4"> <label className="block text-sm font-medium text-gray-700 mb-2"> Your Email * </label> <input {...register('email', { required: 'Email is required', pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: 'Invalid email address' } })} type="email" className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" placeholder="john.doe@example.com" /> {errors.email && ( <span className="text-red-500 text-sm mt-1">{errors.email.message}</span> )} </div>
{/* Subject Field */} <div className="mb-4"> <label className="block text-sm font-medium text-gray-700 mb-2"> Subject * </label> <input {...register('subject', { required: 'Subject is required', minLength: { value: 3, message: 'Subject too short' }, maxLength: { value: 200, message: 'Subject too long' } })} className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" placeholder="Project Inquiry" /> {errors.subject && ( <span className="text-red-500 text-sm mt-1">{errors.subject.message}</span> )} </div>
{/* Message Field */} <div className="mb-6"> <label className="block text-sm font-medium text-gray-700 mb-2"> Message * </label> <textarea {...register('message', { required: 'Message is required', minLength: { value: 10, message: 'Message too short (minimum 10 characters)' }, maxLength: { value: 2000, message: 'Message too long (maximum 2000 characters)' } })} rows="6" className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 resize-none" placeholder="Tell me about your project or inquiry..." /> {errors.message && ( <span className="text-red-500 text-sm mt-1">{errors.message.message}</span> )} <div className="text-xs text-gray-500 mt-1 text-right"> {watch('message')?.length || 0} / 2000 characters </div> </div>
{/* Honeypot Field (Hidden) */} <input {...register('website')} type="text" className="hidden" tabIndex="-1" autoComplete="off" />
{/* Submit Button */} <button type="submit" disabled={loading} className="w-full bg-blue-600 text-white p-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-105" > {loading ? ( <span className="flex items-center justify-center"> <svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/> </svg> Sending... </span> ) : ( 'Send Message' )} </button> </form> )} </div> </div> </div> </div> );}
export default Contact;Admin Interface: Message Management
Section titled “Admin Interface: Message Management”Create an admin dashboard for managing messages:
import { useState, useEffect } from 'react';import { toast } from 'react-toastify';
function ContactMessages() { const [messages, setMessages] = useState([]); const [pagination, setPagination] = useState({}); const [currentPage, setCurrentPage] = useState(1); const [filter, setFilter] = useState('all'); // all, unread, read const [loading, setLoading] = useState(true);
useEffect(() => { fetchMessages(); }, [currentPage, filter]);
const fetchMessages = async () => { setLoading(true); try { const filterParam = filter === 'all' ? '' : `&read=${filter === 'read'}`; const response = await fetch( `/api/v1/contact?page=${currentPage}&per_page=10${filterParam}`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('access_token')}` } } );
const data = await response.json(); setMessages(data.messages); setPagination(data.pagination); } catch (error) { toast.error('Error fetching messages'); } finally { setLoading(false); } };
const markAsRead = async (messageId) => { try { const response = await fetch(`/api/v1/contact/${messageId}/read`, { method: 'PUT', headers: { 'Authorization': `Bearer ${localStorage.getItem('access_token')}` } });
if (response.ok) { toast.success('Message marked as read'); fetchMessages(); } } catch (error) { toast.error('Error updating message'); } };
return ( <div className="p-6"> <div className="mb-6 flex justify-between items-center"> <h1 className="text-3xl font-bold">Contact Messages</h1>
{/* Filter Buttons */} <div className="flex space-x-2"> <button onClick={() => setFilter('all')} className={`px-4 py-2 rounded ${ filter === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-200' }`} > All ({pagination.total || 0}) </button> <button onClick={() => setFilter('unread')} className={`px-4 py-2 rounded ${ filter === 'unread' ? 'bg-blue-600 text-white' : 'bg-gray-200' }`} > Unread </button> <button onClick={() => setFilter('read')} className={`px-4 py-2 rounded ${ filter === 'read' ? 'bg-blue-600 text-white' : 'bg-gray-200' }`} > Read </button> </div> </div>
{/* Messages List */} <div className="space-y-4"> {loading ? ( <div className="text-center py-8">Loading messages...</div> ) : messages.length === 0 ? ( <div className="bg-white p-8 rounded-lg shadow text-center text-gray-500"> No messages found </div> ) : ( messages.map(message => ( <MessageCard key={message.ccn_contact_message} message={message} onMarkAsRead={markAsRead} /> )) )} </div>
{/* Pagination */} {pagination.pages > 1 && ( <div className="mt-6 flex justify-center space-x-2"> <button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={!pagination.has_prev} className="px-4 py-2 border rounded disabled:opacity-50" > Previous </button> <span className="px-4 py-2"> Page {pagination.page} of {pagination.pages} </span> <button onClick={() => setCurrentPage(p => p + 1)} disabled={!pagination.has_next} className="px-4 py-2 border rounded disabled:opacity-50" > Next </button> </div> )} </div> );}
function MessageCard({ message, onMarkAsRead }) { const [expanded, setExpanded] = useState(false);
return ( <div className={`bg-white rounded-lg shadow-md p-6 ${ !message.read ? 'border-l-4 border-blue-500' : '' }`}> <div className="flex justify-between items-start mb-3"> <div className="flex-1"> <div className="flex items-center space-x-2 mb-2"> <h3 className="text-lg font-semibold">{message.subject}</h3> {!message.read && ( <span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full"> NEW </span> )} </div> <div className="text-sm text-gray-600"> From: <span className="font-medium">{message.name}</span> {' '}<{message.email}> </div> <div className="text-xs text-gray-500 mt-1"> {new Date(message.created_at).toLocaleString()} </div> </div>
{/* Actions */} <div className="flex space-x-2"> {!message.read && ( <button onClick={() => onMarkAsRead(message.ccn_contact_message)} className="text-green-600 hover:text-green-800 text-sm" title="Mark as Read" > ✓ Mark Read </button> )} <button onClick={() => setExpanded(!expanded)} className="text-blue-600 hover:text-blue-800 text-sm" > {expanded ? 'Hide' : 'Show'} </button> </div> </div>
{/* Message Content */} {expanded && ( <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="mt-4 pt-4 border-t" > <div className="bg-gray-50 p-4 rounded"> <p className="whitespace-pre-wrap text-gray-700">{message.message}</p> </div>
<div className="mt-4 flex space-x-4"> <a href={`mailto:${message.email}?subject=Re: ${message.subject}`} className="text-blue-600 hover:text-blue-800" > 📧 Reply via Email </a> </div> </motion.div> )} </div> );}
export default ContactMessages;Advanced Spam Protection
Section titled “Advanced Spam Protection”Implement multiple spam prevention layers:
import refrom datetime import datetime
class SpamDetector:
# Common spam keywords SPAM_KEYWORDS = [ 'viagra', 'casino', 'lottery', 'prize', 'winner', 'click here', 'buy now', 'limited time', 'act now' ]
# Suspicious patterns SUSPICIOUS_PATTERNS = [ r'http[s]?://.*http[s]?://', # Multiple URLs r'[A-Z]{10,}', # Excessive capitals r'(.)\1{5,}', # Repeated characters ]
@staticmethod def is_spam(name, email, subject, message): """ Detect spam using multiple heuristics Returns: (is_spam: bool, reason: str) """ content = f"{name} {subject} {message}".lower()
# Check for spam keywords for keyword in SpamDetector.SPAM_KEYWORDS: if keyword in content: return True, f"Spam keyword detected: {keyword}"
# Check suspicious patterns full_text = f"{name} {email} {subject} {message}" for pattern in SpamDetector.SUSPICIOUS_PATTERNS: if re.search(pattern, full_text): return True, f"Suspicious pattern detected"
# Check excessive links link_count = len(re.findall(r'http[s]?://', message)) if link_count > 3: return True, "Too many links"
# Check message length ratio (too short or too long) if len(message.strip()) < 10: return True, "Message too short"
return False, ""
@staticmethod def get_spam_score(name, email, subject, message): """Calculate spam score (0-100, higher = more likely spam)""" score = 0 content = f"{name} {subject} {message}".lower()
# Keyword check score += sum(10 for keyword in SpamDetector.SPAM_KEYWORDS if keyword in content)
# Pattern check full_text = f"{name} {email} {subject} {message}" score += sum(15 for pattern in SpamDetector.SUSPICIOUS_PATTERNS if re.search(pattern, full_text))
# Link check link_count = len(re.findall(r'http[s]?://', message)) score += min(link_count * 10, 30)
# Caps ratio if len(message) > 0: caps_ratio = sum(1 for c in message if c.isupper()) / len(message) if caps_ratio > 0.5: score += 20
return min(score, 100)
# Usage in endpoint@blueprint_api_contact.route("/api/v1/contact", methods=["POST"])def submit_contact_form(): # ... existing validation ...
# Spam detection is_spam, reason = SpamDetector.is_spam( data["name"], data["email"], data["subject"], data["message"] )
if is_spam: print(f"🚫 Spam detected: {reason} from {ip_address}") # Return success to avoid detection return {"message": "Thank you for your message!", "status": "success"}, 200
# ... continue with message creation ...CSRF Protection
Section titled “CSRF Protection”Add CSRF token validation:
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
def create_app(): app = Flask(__name__) # ... other config ...
# Enable CSRF protection csrf.init_app(app)
# Exempt public endpoints csrf.exempt(blueprint_api_contact) # Handle manually
return appRate Limiting with Redis (Production)
Section titled “Rate Limiting with Redis (Production)”import redisfrom datetime import timedelta
class RateLimiter: def __init__(self, redis_client): self.redis = redis_client
def is_allowed(self, key, limit=5, window=3600): """ Check if request is allowed key: unique identifier (e.g., IP address) limit: maximum requests window: time window in seconds """ current_count = self.redis.get(key)
if current_count is None: # First request self.redis.setex(key, window, 1) return True
if int(current_count) >= limit: return False
# Increment counter self.redis.incr(key) return True
# Usageredis_client = redis.Redis(host='localhost', port=6379, db=0)rate_limiter = RateLimiter(redis_client)
@blueprint_api_contact.route("/api/v1/contact", methods=["POST"])def submit_contact_form(): ip_address = request.remote_addr
if not rate_limiter.is_allowed(f"contact:{ip_address}", limit=5, window=3600): return {"error": "Rate limit exceeded"}, 429
# ... rest of the handler ...Email Notification to Admin
Section titled “Email Notification to Admin”Send email notifications for new messages:
from flask_mail import Messagefrom portfolio_app import mail
def send_admin_notification(contact_message): """Send email notification to admin""" try: msg = Message( subject=f"New Contact: {contact_message.subject}", sender="noreply@ruizdev7.com", recipients=["ruizdev7@outlook.com"] )
msg.html = f""" <h2>New Contact Form Submission</h2> <p><strong>From:</strong> {contact_message.name} <{contact_message.email}></p> <p><strong>Subject:</strong> {contact_message.subject}</p> <p><strong>Message:</strong></p> <div style="background: #f5f5f5; padding: 15px; border-radius: 5px;"> {contact_message.message.replace('\n', '<br>')} </div> <p style="margin-top: 20px;"> <a href="https://ruizdev7.com/admin/messages">View in Dashboard</a> </p> """
mail.send(msg) print(f"📨 Admin notification sent for message #{contact_message.ccn_contact_message}") except Exception as e: print(f"❌ Failed to send admin notification: {str(e)}")Testing Your Contact Form
Section titled “Testing Your Contact Form”import pytestfrom portfolio_app.models.tbl_contact_messages import ContactMessage
def test_submit_contact_form(client): """Test contact form submission""" data = { "name": "Test User", "email": "test@example.com", "subject": "Test Message", "message": "This is a test message with enough content." }
response = client.post('/api/v1/contact', json=data) assert response.status_code == 200
# Verify message was saved message = ContactMessage.query.filter_by(email="test@example.com").first() assert message is not None assert message.subject == "Test Message"
def test_honeypot_detection(client): """Test honeypot spam detection""" data = { "name": "Spammer", "email": "spam@example.com", "subject": "Spam", "message": "Spam message", "website": "https://spam.com" # Honeypot field }
response = client.post('/api/v1/contact', json=data) assert response.status_code == 200 # Returns success
# But message should not be saved message = ContactMessage.query.filter_by(email="spam@example.com").first() assert message is None
def test_validation_errors(client): """Test validation""" # Missing required field data = {"name": "Test", "email": "test@example.com"} response = client.post('/api/v1/contact', json=data) assert response.status_code == 400
# Invalid email data = { "name": "Test", "email": "invalid-email", "subject": "Test", "message": "Test message content" } response = client.post('/api/v1/contact', json=data) assert response.status_code == 400Best Practices
Section titled “Best Practices”- Validation: Always validate on both client and server
- Sanitization: Escape HTML to prevent XSS
- Rate Limiting: Prevent abuse with IP-based limits
- Honeypot: Add hidden fields to catch bots
- CAPTCHA: Consider adding reCAPTCHA for high-traffic sites
- Logging: Log all submissions for spam analysis
- Notifications: Send instant alerts for new messages
- Monitoring: Track submission patterns for anomalies
Conclusion
Section titled “Conclusion”You now have a secure contact form with:
- ✅ XSS and CSRF protection
- ✅ Multi-layer spam detection
- ✅ Rate limiting
- ✅ Input validation
- ✅ Admin interface with pagination
- ✅ Read/unread tracking
- ✅ Email notifications
- ✅ Honeypot fields
Future enhancements:
- Google reCAPTCHA integration
- File attachment support
- Automated responses
- Sentiment analysis
- CRM integration
- SMS notifications
The complete implementation is available in my GitHub repository.
Next Steps: Learn about implementing enterprise-grade audit logging!
Questions about security? Connect on LinkedIn!