diff --git a/.env.example b/.env.example index 8bcf022..f4ee832 100644 --- a/.env.example +++ b/.env.example @@ -37,12 +37,32 @@ NODE_ENV=production # ================================== # Contact Form & Email (SMTP) # ================================== -# Used by /api/contact for sending contact form submissions -# If not set, submissions are logged to console (dev/staging mode) -# SMTP_HOST=smtp.gmail.com -# SMTP_PORT=587 -# SMTP_USER=your_email@example.com -# SMTP_PASS=your_app_password_here +# Used by /api/contact (powers both the contact page form AND the +# homepage "Tell us about your project" inline lead form). +# If unset, submissions are logged to console (dev/staging mode). +# +# --- Required for live email delivery --- +# SMTP_HOST=smtp.hostinger.com +# SMTP_PORT=587 # 587 = STARTTLS (recommended) +# SMTP_USER=ghanwat.ajay@workroot.in +# SMTP_PASS=your_app_password_here # MARK AS SECRET in platform UI +# +# --- From header (RFC 5322) --- +# SMTP_FROM=ghanwat.ajay@workroot.in +# SMTP_FROM_NAME=Ajay Ghanwat +# +# --- Admin recipient for inbound submissions --- +# Falls back to SMTP_FROM, then legacy CONTACT_EMAIL_TO/CONTACT_EMAIL. +# MAIL_ADMIN_TO=ghanwat.ajay@workroot.in +# +# --- TLS & timeouts (optional, sensible defaults applied) --- +# SMTP_REQUIRE_TLS=true # set to "false" to disable STARTTLS upgrade +# SMTP_CONNECTION_TIMEOUT_MS=10000 +# SMTP_GREETING_TIMEOUT_MS=10000 +# SMTP_SOCKET_TIMEOUT_MS=10000 +# +# --- Legacy aliases (still honored for backwards compatibility) --- +# CONTACT_EMAIL_TO=hello@workroot.in # CONTACT_EMAIL=hello@workroot.in # ================================== diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index dec46d7..cd8dc24 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -109,53 +109,174 @@ function validateContactForm(data: ContactFormData): string | null { } // ============================================================ -// SMTP transporter (created once at module level for reuse) +// 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. // ============================================================ -let smtpTransporter: any = null; -let smtpVerified = false; -// Verify SMTP connection once at module initialization -(async () => { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_PASS; - const smtpPort = parseInt(process.env.SMTP_PORT ?? '587', 10); +interface SmtpConfig { + host: string; + port: number; + user: string; + pass: string; + fromAddress: string; + fromName: string; + adminRecipient: string; + requireTLS: boolean; + connectionTimeout: number; + greetingTimeout: number; + socketTimeout: number; +} - if (!smtpHost || !smtpUser || !smtpPass) { - logger.info('api.contact', 'SMTP not configured — skipping connection verification'); - return; +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; } - try { + 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'); - smtpTransporter = nodemailer.default.createTransport({ - host: smtpHost, - port: smtpPort, - secure: false, // Port 587 uses STARTTLS, not SSL - auth: { user: smtpUser, pass: smtpPass }, - requireTLS: true, // Force STARTTLS upgrade + 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: { - rejectUnauthorized: false, // Handle Hostinger certificate issues + // 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, }, }); - await smtpTransporter.verify(); - smtpVerified = true; - console.log('SMTP connection verified successfully'); + // 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: smtpHost, - port: smtpPort, - user: smtpUser, + host: cfg.host, + port: cfg.port, + user: cfg.user, }); } catch (err: any) { - smtpVerified = false; - console.error('SMTP connection verification failed:', { + 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, }); @@ -165,16 +286,16 @@ let smtpVerified = false; // ============================================================ // Email sending via SMTP (nodemailer) or no-op in development // ============================================================ -async function sendContactEmail(data: ContactFormData): Promise { - const smtpHost = process.env.SMTP_HOST; - const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_PASS; - const smtpPort = parseInt(process.env.SMTP_PORT ?? '587', 10); - const smtpFrom = process.env.SMTP_FROM; - const recipientEmail = process.env.CONTACT_EMAIL_TO; +interface SendResult { + adminInfo?: { messageId?: string }; + userConfirmInfo?: { messageId?: string }; +} - if (!smtpHost || !smtpUser || !smtpPass || !recipientEmail) { - // SMTP not configured - log and continue (graceful degradation) +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, @@ -185,22 +306,8 @@ async function sendContactEmail(data: ContactFormData): Promise { return; } - // Dynamic import to avoid bundling issues when nodemailer isn't installed try { - // Reuse module-level transporter if available, otherwise create a new one - if (!smtpTransporter) { - const nodemailer = await import('nodemailer'); - smtpTransporter = nodemailer.default.createTransport({ - host: smtpHost, - port: smtpPort, - secure: false, - auth: { user: smtpUser, pass: smtpPass }, - requireTLS: true, - tls: { - rejectUnauthorized: false, - }, - }); - } + const transport = await getTransporter(cfg); const subjectLabels: Record = { 'web-development': 'Web Development', @@ -216,17 +323,31 @@ async function sendContactEmail(data: ContactFormData): Promise { const sourceLabel = data.source ? data.source : 'contact-page'; - const fromAddress = smtpFrom || smtpUser; + // 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'; - // 1) Admin notification email with BCC to SMTP_USER for delivery confirmation - const adminInfo = await smtpTransporter.sendMail({ - from: `"CompanySite Contact Form" <${fromAddress}>`, - to: recipientEmail, - bcc: process.env.SMTP_USER, // BCC to admin inbox as delivery confirmation + 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: `[Contact Form] ${subjectLabels[data.subject] ?? data.subject} - ${data.name}`, + subject: `${adminSubjectPrefix} ${subjectLabels[data.subject] ?? data.subject} - ${data.name}`, text: [ - `New contact form submission`, + isProjectInquiry ? 'New project inquiry submission' : 'New contact form submission', ``, `Source: ${sourceLabel}`, `Name: ${data.name}`, @@ -242,7 +363,7 @@ async function sendContactEmail(data: ContactFormData): Promise { `Submitted at: ${new Date().toISOString()}`, ].join('\n'), html: ` -

New Contact Form Submission

+

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

@@ -256,15 +377,24 @@ async function sendContactEmail(data: ContactFormData): Promise { `, }); - // 2) User confirmation email — proves delivery works AND provides good UX - const userConfirmInfo = await smtpTransporter.sendMail({ - from: `"CompanySite" <${fromAddress}>`, + 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: 'We received your message - CompanySite', + subject: userSubjectLine, text: [ `Hi ${data.name},`, ``, - `Thank you for reaching out to us! We have received your message regarding "${subjectLabels[data.subject] ?? data.subject}".`, + 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.`, ``, @@ -279,9 +409,11 @@ async function sendContactEmail(data: ContactFormData): Promise { ].join('\n'), html: `
-

Thank you for contacting us!

+

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

Hi ${escapeHtml(data.name)},

-

We have received your message regarding "${escapeHtml(subjectLabels[data.subject] ?? data.subject)}".

+

${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)}

@@ -295,9 +427,16 @@ async function sendContactEmail(data: ContactFormData): Promise { `, }); + console.log('[api.contact] User auto-reply accepted by SMTP', { + messageId: userConfirmInfo?.messageId, + recipient: data.email, + source: sourceLabel, + }); + return { adminInfo, userConfirmInfo }; - } catch (err) { - // If nodemailer isn't installed, treat as graceful degradation (log only) + } 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, @@ -305,6 +444,17 @@ async function sendContactEmail(data: ContactFormData): Promise { }); 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 }, @@ -454,19 +604,29 @@ export const POST: APIRoute = async ({ request, clientAddress }) => { // 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 recipientEmail = process.env.CONTACT_EMAIL_TO || 'unknown'; - console.log('Sending contact email to: ' + recipientEmail); + 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('Contact emails delivered successfully:', { - adminRecipient: recipientEmail, + 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: recipientEmail, + adminRecipient, adminMessageId: result?.adminInfo?.messageId, userRecipient: formData.email, userMessageId: result?.userConfirmInfo?.messageId, @@ -476,17 +636,20 @@ export const POST: APIRoute = async ({ request, clientAddress }) => { }); }) .catch(async (err: any) => { - console.error('Background email delivery failed:', { + // 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, - recipient: recipientEmail, - email: formData.email, + 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,
Source${escapeHtml(sourceLabel)}
Name${escapeHtml(data.name)}