feat(api): add contact form endpoint for website enquiries
Add POST /api/v1/contact endpoint to handle contact form submissions from the marketing website. Includes: - ContactSchema for validation with HTML sanitization - Honeypot field for spam protection - Rate limiting (5 per hour) - Email notification to info@secuird.tech via NotificationService
This commit is contained in:
@@ -5,7 +5,9 @@ from flask import Blueprint
|
|||||||
api_v1_bp = Blueprint("api_v1", __name__)
|
api_v1_bp = Blueprint("api_v1", __name__)
|
||||||
|
|
||||||
# Import route modules to register them
|
# Import route modules to register them
|
||||||
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, sudo, oidc
|
from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth, departments, principals, ssh, zerotier, sudo, oidc, contact
|
||||||
|
from gatehouse_app.api.v1 import superadmin
|
||||||
|
|
||||||
api_v1_bp.register_blueprint(ssh.ssh_bp)
|
api_v1_bp.register_blueprint(ssh.ssh_bp)
|
||||||
|
api_v1_bp.register_blueprint(superadmin.superadmin_bp)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Contact form endpoint for website enquiries."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import request, current_app
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
|
from gatehouse_app.extensions import limiter
|
||||||
|
from gatehouse_app.utils.response import api_response
|
||||||
|
from gatehouse_app.schemas.contact_schema import ContactSchema
|
||||||
|
from gatehouse_app.services.notification_service import NotificationService
|
||||||
|
from gatehouse_app.services.email_templates import build_contact_enquiry_html
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Hardcoded destination for all contact submissions
|
||||||
|
CONTACT_DESTINATION = "info@secuird.tech"
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/contact", methods=["POST"])
|
||||||
|
@limiter.limit("5 per hour")
|
||||||
|
def contact():
|
||||||
|
"""Handle contact form submissions from the marketing website.
|
||||||
|
|
||||||
|
Accepts: email, name, company, enquiry_type, message, interest_area, _hp.
|
||||||
|
Sends an email to info@secuird.tech with the enquiry details.
|
||||||
|
Silently discards submissions where the honeypot field (_hp) is filled.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
schema = ContactSchema()
|
||||||
|
data = schema.load(request.get_json() or {})
|
||||||
|
except ValidationError as err:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Invalid request data",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=err.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Honeypot check — silently succeed without sending
|
||||||
|
if data.get("_hp"):
|
||||||
|
logger.info(f"[Contact] Honeypot triggered, ip={request.remote_addr}")
|
||||||
|
return api_response(message="Thank you for your message!")
|
||||||
|
|
||||||
|
enquiry_type = data.get("enquiry_type") or "general"
|
||||||
|
email = data.get("email") or ""
|
||||||
|
|
||||||
|
# Build and send email
|
||||||
|
html_body = build_contact_enquiry_html(
|
||||||
|
enquiry_type=enquiry_type,
|
||||||
|
submitter_email=email,
|
||||||
|
name=data.get("name"),
|
||||||
|
company=data.get("company"),
|
||||||
|
interest_area=data.get("interest_area"),
|
||||||
|
message=data.get("message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationService._send_email_async(
|
||||||
|
to_address=CONTACT_DESTINATION,
|
||||||
|
subject=f"Secuird Website: {enquiry_type.replace('_', ' ').title()} from {email}",
|
||||||
|
body=f"New contact enquiry ({enquiry_type}) from {email}",
|
||||||
|
html_body=html_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[Contact] enquiry_type={enquiry_type} ip={request.remote_addr}")
|
||||||
|
|
||||||
|
return api_response(message="Thank you for your message!")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Contact form validation schemas."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from marshmallow import Schema, fields, validate, validates_schema, ValidationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSchema(Schema):
|
||||||
|
"""Schema for contact form submissions."""
|
||||||
|
|
||||||
|
email = fields.Email(required=True)
|
||||||
|
name = fields.Str(
|
||||||
|
allow_none=True,
|
||||||
|
load_default=None,
|
||||||
|
validate=validate.Length(max=255),
|
||||||
|
)
|
||||||
|
company = fields.Str(
|
||||||
|
allow_none=True,
|
||||||
|
load_default=None,
|
||||||
|
validate=validate.Length(max=255),
|
||||||
|
)
|
||||||
|
enquiry_type = fields.Str(
|
||||||
|
required=True,
|
||||||
|
validate=validate.OneOf(["demo_request", "sales_enquiry", "general", "support"]),
|
||||||
|
)
|
||||||
|
message = fields.Str(
|
||||||
|
allow_none=True,
|
||||||
|
load_default=None,
|
||||||
|
validate=validate.Length(max=2000),
|
||||||
|
)
|
||||||
|
interest_area = fields.Str(
|
||||||
|
allow_none=True,
|
||||||
|
load_default=None,
|
||||||
|
validate=validate.Length(max=100),
|
||||||
|
)
|
||||||
|
_hp = fields.Str(
|
||||||
|
allow_none=True,
|
||||||
|
load_default=None,
|
||||||
|
load_from="_hp",
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates_schema
|
||||||
|
def sanitize_html(self, data, **kwargs):
|
||||||
|
"""Strip HTML tags from all text fields to prevent XSS."""
|
||||||
|
text_fields = ["name", "company", "message", "interest_area"]
|
||||||
|
for field in text_fields:
|
||||||
|
value = data.get(field)
|
||||||
|
if value and isinstance(value, str):
|
||||||
|
data[field] = re.sub(r"<[^>]*>", "", value)
|
||||||
@@ -496,3 +496,69 @@ def build_email_verification_resend_html(
|
|||||||
{get_alert_box("If you didn't request this, you can safely ignore this email.", "info", "🔒")}
|
{get_alert_box("If you didn't request this, you can safely ignore this email.", "info", "🔒")}
|
||||||
'''
|
'''
|
||||||
return get_base_html(content, "Verify your Secuird email address", "Please verify your email address")
|
return get_base_html(content, "Verify your Secuird email address", "Please verify your email address")
|
||||||
|
|
||||||
|
|
||||||
|
def build_contact_enquiry_html(
|
||||||
|
enquiry_type: str,
|
||||||
|
submitter_email: str,
|
||||||
|
name: Optional[str],
|
||||||
|
company: Optional[str],
|
||||||
|
interest_area: Optional[str],
|
||||||
|
message: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Build a contact enquiry notification email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enquiry_type: One of demo_request, sales_enquiry, general, support
|
||||||
|
submitter_email: Email address of the person submitting the enquiry
|
||||||
|
name: Full name of the submitter (optional)
|
||||||
|
company: Company name (optional)
|
||||||
|
interest_area: Area of interest (optional)
|
||||||
|
message: Free-text message (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML email string
|
||||||
|
"""
|
||||||
|
# Map enquiry types to display labels and colors
|
||||||
|
type_labels = {
|
||||||
|
"demo_request": ("Demo Request", "info"),
|
||||||
|
"sales_enquiry": ("Sales Enquiry", "success"),
|
||||||
|
"general": ("General Enquiry", "info"),
|
||||||
|
"support": ("Support Request", "warning"),
|
||||||
|
}
|
||||||
|
type_label, alert_type = type_labels.get(enquiry_type, ("Enquiry", "info"))
|
||||||
|
|
||||||
|
name_display = name if name else "Not provided"
|
||||||
|
company_display = company if company else "Not provided"
|
||||||
|
interest_display = interest_area if interest_area else "Not provided"
|
||||||
|
message_display = message if message else "No message provided"
|
||||||
|
|
||||||
|
# Build details table
|
||||||
|
details_rows = f"""
|
||||||
|
{get_detail_row("Enquiry Type", type_label)}
|
||||||
|
{get_detail_row("Submitter Email", submitter_email)}
|
||||||
|
{get_detail_row("Name", name_display)}
|
||||||
|
{get_detail_row("Company", company_display)}
|
||||||
|
{get_detail_row("Interest Area", interest_display)}
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = f'''
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">New {type_label}</h2>
|
||||||
|
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
|
||||||
|
A new {type_label.lower()} has been submitted through the Secuird website.
|
||||||
|
</p>
|
||||||
|
{get_alert_box(f"Enquiry type: <strong>{type_label}</strong>", alert_type, "📬")}
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px;">
|
||||||
|
<h3 style="margin: 0 0 16px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Enquiry Details</h3>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||||
|
{details_rows}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h3 style="margin: 20px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Message</h3>
|
||||||
|
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px; line-height: 1.6; white-space: pre-wrap;">{message_display}</p>
|
||||||
|
'''
|
||||||
|
return get_base_html(content, f"Secuird Website: {type_label}", f"New {type_label} from {submitter_email}")
|
||||||
|
|||||||
Reference in New Issue
Block a user