import type { APIRoute } from 'astro'; import { logger, logApiRequest } from '../../utils/logger'; import { captureException } from '../../utils/sentry'; // ============================================================ // In-memory rate limiter (sliding window, per IP) // ============================================================ interface RateLimitEntry { timestamps: number[]; } const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour const RATE_LIMIT_MAX_REQUESTS = 5; // max 5 contact submissions per hour per IP function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetAt: number } { const now = Date.now(); const windowStart = now - RATE_LIMIT_WINDOW_MS; const entry = rateLimitStore.get(ip) ?? { timestamps: [] }; // Remove timestamps outside the window entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart); const remaining = RATE_LIMIT_MAX_REQUESTS - entry.timestamps.length; const resetAt = entry.timestamps.length > 0 ? entry.timestamps[0] + RATE_LIMIT_WINDOW_MS : now + RATE_LIMIT_WINDOW_MS; if (entry.timestamps.length >= RATE_LIMIT_MAX_REQUESTS) { rateLimitStore.set(ip, entry); return { allowed: false, remaining: 0, resetAt }; } entry.timestamps.push(now); rateLimitStore.set(ip, entry); return { allowed: true, remaining: remaining - 1, resetAt }; } // ============================================================ // Input validation // ============================================================ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PHONE_REGEX = /^[\+]?[(]?[0-9]{1,3}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,4}[-\s\.]?[0-9]{1,9}$/; const VALID_SUBJECTS = [ 'web-development', 'mobile-development', 'cloud-services', 'ai-ml', 'consulting', 'support', 'erp-solutions', 'government-systems', 'other', ]; // Allowed lead-source tags. Used so leads can be segmented downstream // (e.g. "homepage-inline" vs the regular /contact page submissions). // Anything outside this allowlist is dropped to "unknown" to keep the // downstream analytics tidy and prevent free-text injection into logs. const VALID_SOURCES = [ 'contact-page', 'homepage-inline', 'homepage-hero', 'homepage-final-cta', 'mobile-sticky', 'whatsapp-fab', 'unknown', ]; interface ContactFormData { name: string; email: string; phone?: string; subject: string; message: string; website?: string; // honeypot source?: string; projectType?: string; } function validateContactForm(data: ContactFormData): string | null { if (!data.name || data.name.trim().length < 2) { return 'Name must be at least 2 characters.'; } if (data.name.trim().length > 100) { return 'Name must be under 100 characters.'; } if (!data.email || !EMAIL_REGEX.test(data.email.trim())) { return 'A valid email address is required.'; } if (data.email.trim().length > 254) { return 'Email address is too long.'; } if (data.phone && data.phone.trim() !== '' && !PHONE_REGEX.test(data.phone.trim())) { return 'Phone number format is invalid.'; } if (!data.subject || !VALID_SUBJECTS.includes(data.subject)) { return 'Please select a valid subject.'; } if (!data.message || data.message.trim().length < 10) { return 'Message must be at least 10 characters.'; } if (data.message.trim().length > 5000) { return 'Message must be under 5000 characters.'; } return null; } // ============================================================ // SMTP transporter — env-driven, created once at module level // ============================================================ // // Required env vars: // SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS // SMTP_FROM, SMTP_FROM_NAME // MAIL_ADMIN_TO (admin recipient; falls back to SMTP_FROM) // Optional env vars (with sensible defaults): // SMTP_REQUIRE_TLS ("false" disables STARTTLS upgrade; default: required) // SMTP_CONNECTION_TIMEOUT_MS (default 10000) // SMTP_GREETING_TIMEOUT_MS (default 10000) // SMTP_SOCKET_TIMEOUT_MS (default 10000) // // Graceful degradation: if the nodemailer import fails, or any of the // required env vars are missing, we log a structured warning and let // the POST handler return success to the user. The site never 500s // because of email config — submissions surface in server logs and // Sentry instead. // ============================================================ interface SmtpConfig { host: string; port: number; user: string; pass: string; fromAddress: string; fromName: string; adminRecipient: string; requireTLS: boolean; connectionTimeout: number; greetingTimeout: number; socketTimeout: number; } function readSmtpConfig(): SmtpConfig | null { const host = process.env.SMTP_HOST; const user = process.env.SMTP_USER; const pass = process.env.SMTP_PASS; const fromAddress = process.env.SMTP_FROM || user || ''; const fromName = process.env.SMTP_FROM_NAME || 'CompanySite'; // Admin recipient resolution chain: // MAIL_ADMIN_TO > SMTP_FROM > legacy CONTACT_EMAIL_TO > legacy CONTACT_EMAIL > SMTP_USER const adminRecipient = process.env.MAIL_ADMIN_TO || process.env.SMTP_FROM || process.env.CONTACT_EMAIL_TO || process.env.CONTACT_EMAIL || user || ''; if (!host || !user || !pass || !fromAddress || !adminRecipient) { return null; } return { host, port: Number(process.env.SMTP_PORT ?? 587), user, pass, fromAddress, fromName, adminRecipient, // 587 uses STARTTLS — `secure:false` + `requireTLS:true` is the // correct combination. The flag lets ops disable TLS enforcement // by setting SMTP_REQUIRE_TLS=false (e.g. local mailcatcher). requireTLS: process.env.SMTP_REQUIRE_TLS !== 'false', connectionTimeout: Number(process.env.SMTP_CONNECTION_TIMEOUT_MS ?? 10000), greetingTimeout: Number(process.env.SMTP_GREETING_TIMEOUT_MS ?? 10000), socketTimeout: Number(process.env.SMTP_SOCKET_TIMEOUT_MS ?? 10000), }; } let smtpTransporter: any = null; let smtpTransporterPromise: Promise | null = null; async function getTransporter(cfg: SmtpConfig): Promise { if (smtpTransporter) return smtpTransporter; if (smtpTransporterPromise) return smtpTransporterPromise; smtpTransporterPromise = (async () => { const nodemailer = await import('nodemailer'); const transport = nodemailer.default.createTransport({ host: cfg.host, port: cfg.port, secure: false, // 587 uses STARTTLS, not implicit TLS requireTLS: cfg.requireTLS, auth: { user: cfg.user, pass: cfg.pass, }, connectionTimeout: cfg.connectionTimeout, greetingTimeout: cfg.greetingTimeout, socketTimeout: cfg.socketTimeout, tls: { // Hostinger occasionally serves an intermediate cert chain that // node's default verifier rejects. Keep this off for parity with // the prior working configuration. rejectUnauthorized: false, }, }); // NOTE: we deliberately do NOT log cfg.pass. Logs go to platform // stdout and are user-visible in the Operations → Logs tab. console.log('[api.contact] SMTP transport created', { host: cfg.host, port: cfg.port, user: cfg.user, from: `"${cfg.fromName}" <${cfg.fromAddress}>`, adminRecipient: cfg.adminRecipient, requireTLS: cfg.requireTLS, timeouts: { connection: cfg.connectionTimeout, greeting: cfg.greetingTimeout, socket: cfg.socketTimeout, }, }); logger.info('api.contact', 'SMTP transport created', { host: cfg.host, port: cfg.port, user: cfg.user, requireTLS: cfg.requireTLS, }); smtpTransporter = transport; return transport; })(); try { return await smtpTransporterPromise; } catch (err) { // Reset so a subsequent request can retry instead of being permanently // wedged on a transient nodemailer import or DNS failure. smtpTransporterPromise = null; throw err; } } // Background verify — best-effort, never throws. Hits SMTP at boot so the // first user submission doesn't pay the TCP+STARTTLS handshake latency. (async () => { const cfg = readSmtpConfig(); if (!cfg) { logger.info('api.contact', 'SMTP not configured — skipping boot-time verification'); return; } try { const transport = await getTransporter(cfg); await transport.verify(); console.log('[api.contact] SMTP connection verified successfully', { host: cfg.host, port: cfg.port, user: cfg.user, }); logger.info('api.contact', 'SMTP connection verified successfully', { host: cfg.host, port: cfg.port, user: cfg.user, }); } catch (err: any) { console.error('[api.contact] SMTP connection verification failed', { code: err?.code, command: err?.command, message: err?.message, response: err?.response, }); logger.error('api.contact', 'SMTP connection verification failed', { code: err?.code, command: err?.command, message: err?.message, response: err?.response, }); } })(); // ============================================================ // Email sending via SMTP (nodemailer) or no-op in development // ============================================================ interface SendResult { adminInfo?: { messageId?: string }; userConfirmInfo?: { messageId?: string }; } async function sendContactEmail(data: ContactFormData): Promise { const cfg = readSmtpConfig(); if (!cfg) { // SMTP not configured — log and continue (graceful degradation) logger.info('api.contact', 'SMTP not configured — form submission logged only', { email: data.email, subject: data.subject, name: data.name, source: data.source, projectType: data.projectType, }); return; } try { const transport = await getTransporter(cfg); const subjectLabels: Record = { 'web-development': 'Web Development', 'mobile-development': 'Mobile Development', 'cloud-services': 'Cloud Services', 'ai-ml': 'AI & Machine Learning', 'consulting': 'IT Consulting', 'support': 'Technical Support', 'erp-solutions': 'ERP / Custom Software', 'government-systems': 'Government / GeM', 'other': 'Other', }; const sourceLabel = data.source ? data.source : 'contact-page'; // The "Tell us about your project" inline form on the homepage and the // mobile sticky CTA tag themselves as project-inquiry sources. Use a // distinct subject prefix so the admin inbox can be triaged easily. const isProjectInquiry = data.source === 'homepage-inline' || data.source === 'homepage-hero' || data.source === 'homepage-final-cta' || data.source === 'mobile-sticky'; const adminSubjectPrefix = isProjectInquiry ? '[Project Inquiry]' : '[Contact Form]'; const userSubjectLine = isProjectInquiry ? "We received your project inquiry - CompanySite" : 'We received your message - CompanySite'; const fromHeader = `"${cfg.fromName}" <${cfg.fromAddress}>`; // 1) Admin notification email — delivered to MAIL_ADMIN_TO (or SMTP_FROM // fallback). BCC to SMTP_USER as a soft delivery receipt. const adminInfo = await transport.sendMail({ from: fromHeader, to: cfg.adminRecipient, bcc: cfg.user, // BCC to the SMTP mailbox as delivery confirmation replyTo: data.email, subject: `${adminSubjectPrefix} ${subjectLabels[data.subject] ?? data.subject} - ${data.name}`, text: [ isProjectInquiry ? 'New project inquiry submission' : 'New contact form submission', ``, `Source: ${sourceLabel}`, `Name: ${data.name}`, `Email: ${data.email}`, `Phone: ${data.phone || 'Not provided'}`, `Subject: ${subjectLabels[data.subject] ?? data.subject}`, ...(data.projectType ? [`Project: ${data.projectType}`] : []), ``, `Message:`, data.message, ``, `---`, `Submitted at: ${new Date().toISOString()}`, ].join('\n'), html: `

${isProjectInquiry ? 'New Project Inquiry' : 'New Contact Form Submission'}

${data.projectType ? `` : ''}
Source${escapeHtml(sourceLabel)}
Name${escapeHtml(data.name)}
Email${escapeHtml(data.email)}
Phone${escapeHtml(data.phone || 'Not provided')}
Subject${escapeHtml(subjectLabels[data.subject] ?? data.subject)}
Project${escapeHtml(data.projectType)}
Message${escapeHtml(data.message)}

Submitted at: ${new Date().toISOString()}

`, }); console.log('[api.contact] Admin notification accepted by SMTP', { messageId: adminInfo?.messageId, recipient: cfg.adminRecipient, source: sourceLabel, }); // 2) User confirmation email (auto-reply) — preserves the existing UX // pattern. Subject line adapts to whether this is a project inquiry. const userConfirmInfo = await transport.sendMail({ from: fromHeader, to: data.email, subject: userSubjectLine, text: [ `Hi ${data.name},`, ``, isProjectInquiry ? `Thank you for telling us about your project. We have received your inquiry regarding "${subjectLabels[data.subject] ?? data.subject}".` : `Thank you for reaching out to us! We have received your message regarding "${subjectLabels[data.subject] ?? data.subject}".`, ``, `Our team will review your inquiry and get back to you as soon as possible, typically within 1-2 business days.`, ``, `For your reference, here is a summary of your submission:`, ` Subject: ${subjectLabels[data.subject] ?? data.subject}`, ` Date: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`, ``, `If you have any urgent questions, feel free to reply to this email.`, ``, `Best regards,`, `The CompanySite Team`, ].join('\n'), html: `

${isProjectInquiry ? 'Thank you for telling us about your project!' : 'Thank you for contacting us!'}

Hi ${escapeHtml(data.name)},

${isProjectInquiry ? `Thank you for telling us about your project. We have received your inquiry regarding "${escapeHtml(subjectLabels[data.subject] ?? data.subject)}".` : `We have received your message regarding "${escapeHtml(subjectLabels[data.subject] ?? data.subject)}".`}

Our team will review your inquiry and get back to you as soon as possible, typically within 1-2 business days.

Subject: ${escapeHtml(subjectLabels[data.subject] ?? data.subject)}

Date: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}

If you have any urgent questions, feel free to reply to this email.

Best regards,
The CompanySite Team


This is an automated confirmation. Please do not reply unless you need further assistance.

`, }); console.log('[api.contact] User auto-reply accepted by SMTP', { messageId: userConfirmInfo?.messageId, recipient: data.email, source: sourceLabel, }); return { adminInfo, userConfirmInfo }; } catch (err: any) { // If nodemailer isn't installed, treat as graceful degradation (log only). // The site keeps working; ops sees the warning and ships the dependency. if (err instanceof Error && err.message.includes('Cannot find module')) { logger.warn('api.contact', 'nodemailer not available — form submission logged only', { email: data.email, subject: data.subject, }); return; } // Structured send-error log. Deliberately omits SMTP_PASS — only the // SMTP server's response/code/command. Sentry captures the full error. console.error('[api.contact] SMTP send failed', { code: err?.code, command: err?.command, response: err?.response, responseCode: err?.responseCode, message: err?.message, recipient: data.email, adminRecipient: cfg.adminRecipient, }); await captureException(err, { scope: 'api.contact', extra: { email: data.email, subject: data.subject }, }); throw new Error('Email delivery failed'); } } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ============================================================ // CORS headers helper // ============================================================ const ALLOWED_ORIGINS = ['https://workroot.in', 'https://www.workroot.in']; function corsHeaders(requestOrigin?: string | null): HeadersInit { if (!import.meta.env.PROD) { return { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; } // Reflect the request origin if it is in the allowlist, otherwise default to primary domain const origin = requestOrigin && ALLOWED_ORIGINS.includes(requestOrigin) ? requestOrigin : 'https://workroot.in'; return { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Vary': 'Origin', }; } // ============================================================ // OPTIONS preflight handler // ============================================================ export const OPTIONS: APIRoute = async ({ request }) => { return new Response(null, { status: 204, headers: corsHeaders(request.headers.get('origin')) }); }; // ============================================================ // POST handler // ============================================================ export const POST: APIRoute = async ({ request, clientAddress }) => { const ip = clientAddress ?? 'unknown'; const startTime = Date.now(); const requestOrigin = request.headers.get('origin'); // Rate limiting check const rateLimit = checkRateLimit(ip); const rateLimitHeaders = { ...corsHeaders(requestOrigin), 'X-RateLimit-Limit': String(RATE_LIMIT_MAX_REQUESTS), 'X-RateLimit-Remaining': String(rateLimit.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), }; if (!rateLimit.allowed) { logApiRequest({ scope: 'api.contact', method: 'POST', path: '/api/contact', status: 429, ip }); return new Response( JSON.stringify({ success: false, error: 'Too many requests. Please try again later.', }), { status: 429, headers: rateLimitHeaders } ); } // Reject oversized payloads (max 16KB) const contentLength = parseInt(request.headers.get('content-length') ?? '0', 10); if (contentLength > 16384) { return new Response( JSON.stringify({ success: false, error: 'Request body too large.' }), { status: 413, headers: rateLimitHeaders } ); } // Parse request body let body: Record; try { const contentType = request.headers.get('content-type') ?? ''; if (contentType.includes('application/json')) { body = await request.json(); } else { const formData = await request.formData(); body = Object.fromEntries(formData.entries()); } } catch { return new Response( JSON.stringify({ success: false, error: 'Invalid request body.' }), { status: 400, headers: rateLimitHeaders } ); } // Honeypot check (spam bot detection) // Bots typically fill all fields; the honeypot field should always be empty if (body.website && String(body.website).trim() !== '') { // Silently succeed to not reveal spam detection return new Response( JSON.stringify({ success: true, message: 'Message received. Thank you!' }), { status: 200, headers: rateLimitHeaders } ); } // Normalize the lead source. Free-text is rejected to keep the analytics // taxonomy clean and prevent log-injection style abuse. const rawSource = body.source ? String(body.source).trim().toLowerCase() : ''; const source = VALID_SOURCES.includes(rawSource) ? rawSource : 'contact-page'; // Project type is free-text from a controlled