Files
CompanySite/src/pages/api/contact.ts
T
platform-mcp e4b962bb1b
Ping Search Engines / Notify Search Engines (push) Successful in 5s
E2E Test Suite / Form Interaction Tests (push) Failing after 10m11s
E2E Test Suite / Mobile Device Tests (push) Failing after 10m49s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 11m42s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 12m34s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 13m26s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 14m19s
E2E Test Suite / Security Header Tests (push) Failing after 14m55s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 20m54s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Test Report Summary (push) Has been cancelled
Deploy to Production / Build & Verify (push) Waiting to run
Deploy to Production / Pre-Deploy Tests (push) Blocked by required conditions
Deploy to Production / Deploy to Railway (push) Has been cancelled
Deploy to Production / Deploy to Render (push) Has been cancelled
Deploy to Production / Deploy to VPS (PM2) (push) Has been cancelled
Deploy to Production / Deploy to Fly.io (push) Has been cancelled
Deploy to Production / Post-Deploy Verification (push) Has been cancelled
Deploy to Production / Notify on Failure (push) Has been cancelled
feat(api/contact): wire env-driven SMTP for contact + project inquiry forms
Both the contact form (/contact) and the homepage "Tell us about your project"
inline lead form post to /api/contact. Updated that single handler to build
the nodemailer transport from a clean set of SMTP_* env vars per spec:

  SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS
  SMTP_FROM + SMTP_FROM_NAME  -> "From: Ajay Ghanwat <ghanwat.ajay@workroot.in>"
  MAIL_ADMIN_TO               -> admin notification recipient
                                  (falls back to SMTP_FROM, then legacy
                                   CONTACT_EMAIL_TO / CONTACT_EMAIL / SMTP_USER)
  SMTP_REQUIRE_TLS            -> force STARTTLS upgrade on port 587
  SMTP_CONNECTION_TIMEOUT_MS  -> defaults to 10000
  SMTP_GREETING_TIMEOUT_MS    -> defaults to 10000
  SMTP_SOCKET_TIMEOUT_MS      -> defaults to 10000

Behaviour preserved:
- Existing user-facing auto-reply (the block previously at L270) kept.
  Subject + body now adapt for project-inquiry sources (homepage-inline,
  homepage-hero, homepage-final-cta, mobile-sticky) so the admin inbox
  can triage [Project Inquiry] vs [Contact Form] at a glance.
- Graceful degradation per FORM_INTEGRATION.md: missing env vars or a
  failed nodemailer import logs a structured warning and returns 200 —
  the site never 500s on email config.
- Rate limiting, honeypot, CORS, validation, and fire-and-forget
  background dispatch are untouched.

Observability:
- Structured console logs at: transport created, admin accepted (with
  messageId), user auto-reply accepted (with messageId), and send-failure
  (with smtp code/command/response). SMTP_PASS is never logged.

Misc:
- .env.example updated to document the new variables and the legacy
  aliases that are still honored.
2026-06-17 12:25:08 +05:30

687 lines
25 KiB
TypeScript

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<string, RateLimitEntry>();
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<any> | null = null;
async function getTransporter(cfg: SmtpConfig): Promise<any> {
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<SendResult | void> {
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<string, string> = {
'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: `
<h2>${isProjectInquiry ? 'New Project Inquiry' : 'New Contact Form Submission'}</h2>
<table style="border-collapse:collapse;width:100%;max-width:600px">
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Source</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(sourceLabel)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Name</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Email</td><td style="padding:8px;border-bottom:1px solid #eee"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Phone</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.phone || 'Not provided')}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Subject</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(subjectLabels[data.subject] ?? data.subject)}</td></tr>
${data.projectType ? `<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Project</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.projectType)}</td></tr>` : ''}
<tr><td style="padding:8px;font-weight:bold;vertical-align:top">Message</td><td style="padding:8px;white-space:pre-wrap">${escapeHtml(data.message)}</td></tr>
</table>
<p style="color:#888;font-size:12px;margin-top:16px">Submitted at: ${new Date().toISOString()}</p>
`,
});
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: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px">
<h2 style="color:#333">${isProjectInquiry ? 'Thank you for telling us about your project!' : 'Thank you for contacting us!'}</h2>
<p>Hi ${escapeHtml(data.name)},</p>
<p>${isProjectInquiry
? `Thank you for telling us about your project. We have received your inquiry regarding <strong>"${escapeHtml(subjectLabels[data.subject] ?? data.subject)}"</strong>.`
: `We have received your message regarding <strong>"${escapeHtml(subjectLabels[data.subject] ?? data.subject)}"</strong>.`}</p>
<p>Our team will review your inquiry and get back to you as soon as possible, typically within <strong>1-2 business days</strong>.</p>
<div style="background:#f9f9f9;border-left:4px solid #007bff;padding:12px 16px;margin:20px 0">
<p style="margin:4px 0"><strong>Subject:</strong> ${escapeHtml(subjectLabels[data.subject] ?? data.subject)}</p>
<p style="margin:4px 0"><strong>Date:</strong> ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
<p>If you have any urgent questions, feel free to reply to this email.</p>
<p style="margin-top:24px">Best regards,<br><strong>The CompanySite Team</strong></p>
<hr style="border:none;border-top:1px solid #eee;margin-top:24px">
<p style="color:#888;font-size:12px">This is an automated confirmation. Please do not reply unless you need further assistance.</p>
</div>
`,
});
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ============================================================
// 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<string, unknown>;
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 <select>. We only echo it
// into the admin email and logs — never used in routing — so allow any
// short reasonable label and just trim/cap it.
const rawProjectType = body.projectType ? String(body.projectType).trim() : '';
const projectType = rawProjectType.length > 0 && rawProjectType.length <= 80
? rawProjectType
: undefined;
// Build and validate form data
const formData: ContactFormData = {
name: String(body.name ?? '').trim(),
email: String(body.email ?? '').trim(),
phone: body.phone ? String(body.phone).trim() : undefined,
subject: String(body.subject ?? '').trim(),
message: String(body.message ?? '').trim(),
source,
projectType,
};
const validationError = validateContactForm(formData);
if (validationError) {
return new Response(
JSON.stringify({ success: false, error: validationError }),
{ status: 422, headers: rateLimitHeaders }
);
}
// Fire-and-forget: send email in background so the user gets an instant response.
// Errors are logged/reported but never block the HTTP reply.
const adminRecipient =
process.env.MAIL_ADMIN_TO ||
process.env.SMTP_FROM ||
process.env.CONTACT_EMAIL_TO ||
process.env.CONTACT_EMAIL ||
process.env.SMTP_USER ||
'unknown';
console.log('[api.contact] Dispatching contact email', {
adminRecipient,
userRecipient: formData.email,
source: formData.source,
});
sendContactEmail(formData)
.then((result) => {
console.log('[api.contact] Contact emails delivered successfully', {
adminRecipient,
adminMessageId: result?.adminInfo?.messageId,
userRecipient: formData.email,
userMessageId: result?.userConfirmInfo?.messageId,
});
logger.info('api.contact', 'Contact emails delivered successfully', {
adminRecipient,
adminMessageId: result?.adminInfo?.messageId,
userRecipient: formData.email,
userMessageId: result?.userConfirmInfo?.messageId,
subject: formData.subject,
source: formData.source,
projectType: formData.projectType,
});
})
.catch(async (err: any) => {
// SMTP_PASS is NEVER logged — only protocol-level error fields.
console.error('[api.contact] Background email delivery failed', {
code: err?.code,
command: err?.command,
message: err?.message,
response: err?.response,
responseCode: err?.responseCode,
adminRecipient,
userRecipient: formData.email,
subject: formData.subject,
});
logger.error('api.contact', 'Background email delivery failed', {
code: err?.code,
command: err?.command,
message: err?.message,
response: err?.response,
responseCode: err?.responseCode,
email: formData.email,
subject: formData.subject,
});
await captureException(err, {
scope: 'api.contact.background',
extra: {
email: formData.email,
subject: formData.subject,
smtpCode: err?.code,
smtpResponse: err?.response,
smtpResponseCode: err?.responseCode,
},
});
});
logApiRequest({
scope: 'api.contact',
method: 'POST',
path: '/api/contact',
status: 200,
ip,
durationMs: Date.now() - startTime,
});
return new Response(
JSON.stringify({
success: true,
message: 'Your message has been received. We will get back to you soon.',
}),
{ status: 200, headers: rateLimitHeaders }
);
};