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:
2026-04-17 15:55:19 +09:30
parent 7480e9d62b
commit 29d54ca109
4 changed files with 188 additions and 1 deletions
+3 -1
View File
@@ -5,7 +5,9 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# 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(superadmin.superadmin_bp)
+68
View File
@@ -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!")
+51
View File
@@ -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)
+66
View File
@@ -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", "🔒")}
'''
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}")