diff --git a/api/contact.js b/api/contact.js index 8cc4e84ca..4fe362146 100644 --- a/api/contact.js +++ b/api/contact.js @@ -4,9 +4,23 @@ import { ConvexHttpClient } from 'convex/browser'; import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/; const MAX_FIELD = 500; const MAX_MESSAGE = 2000; +const FREE_EMAIL_DOMAINS = new Set([ + 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp', + 'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr', + 'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com', + 'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', 'yandex.com', 'yandex.ru', + 'gmx.com', 'gmx.net', 'gmx.de', 'web.de', 'mail.ru', 'inbox.com', + 'fastmail.com', 'tutanota.com', 'tuta.io', 'hey.com', + 'qq.com', '163.com', '126.com', 'sina.com', 'foxmail.com', + 'rediffmail.com', 'ymail.com', 'rocketmail.com', + 'wanadoo.fr', 'free.fr', 'laposte.net', 'orange.fr', 'sfr.fr', + 't-online.de', 'libero.it', 'virgilio.it', +]); + const rateLimitMap = new Map(); const RATE_LIMIT = 3; const RATE_WINDOW_MS = 60 * 60 * 1000; @@ -45,13 +59,14 @@ async function verifyTurnstile(token, ip) { } } -async function sendNotificationEmail(name, email, organization, message) { +async function sendNotificationEmail(name, email, organization, phone, message) { const resendKey = process.env.RESEND_API_KEY; if (!resendKey) { console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent'); return false; } const notifyEmail = process.env.CONTACT_NOTIFY_EMAIL || 'sales@worldmonitor.app'; + const emailDomain = (email.split('@')[1] || '').toLowerCase(); try { const res = await fetch('https://api.resend.com/emails', { method: 'POST', @@ -62,14 +77,16 @@ async function sendNotificationEmail(name, email, organization, message) { body: JSON.stringify({ from: 'World Monitor ', to: [notifyEmail], - subject: `[WM Enterprise] ${sanitizeForSubject(name)} from ${sanitizeForSubject(organization || 'N/A')}`, + subject: `[WM Enterprise] ${sanitizeForSubject(name)} from ${sanitizeForSubject(organization)}`, html: `

New Enterprise Contact

- + + +
Name${escapeHtml(name)}
Email${escapeHtml(email)}
Organization${escapeHtml(organization || 'N/A')}
Domain${escapeHtml(emailDomain)}
Company${escapeHtml(organization)}
Phone${escapeHtml(phone)}
Message${escapeHtml(message || 'N/A')}

Sent from worldmonitor.app enterprise contact form

@@ -159,7 +176,7 @@ export default async function handler(req) { }); } - const { email, name, organization, message, source } = body; + const { email, name, organization, phone, message, source } = body; if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) { return new Response(JSON.stringify({ error: 'Invalid email' }), { @@ -167,15 +184,37 @@ export default async function handler(req) { headers: { 'Content-Type': 'application/json', ...cors }, }); } + + const emailDomain = email.split('@')[1]?.toLowerCase(); + if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) { + return new Response(JSON.stringify({ error: 'Please use your work email address' }), { + status: 422, + headers: { 'Content-Type': 'application/json', ...cors }, + }); + } + if (!name || typeof name !== 'string' || name.trim().length === 0) { return new Response(JSON.stringify({ error: 'Name is required' }), { status: 400, headers: { 'Content-Type': 'application/json', ...cors }, }); } + if (!organization || typeof organization !== 'string' || organization.trim().length === 0) { + return new Response(JSON.stringify({ error: 'Company is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...cors }, + }); + } + if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) { + return new Response(JSON.stringify({ error: 'Valid phone number is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...cors }, + }); + } const safeName = name.slice(0, MAX_FIELD); - const safeOrg = typeof organization === 'string' ? organization.slice(0, MAX_FIELD) : undefined; + const safeOrg = organization.slice(0, MAX_FIELD); + const safePhone = phone.trim().slice(0, 30); const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined; const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact'; @@ -193,11 +232,12 @@ export default async function handler(req) { name: safeName, email: email.trim(), organization: safeOrg, + phone: safePhone, message: safeMsg, source: safeSource, }); - const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safeMsg); + const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg); return new Response(JSON.stringify({ status: 'sent', emailSent }), { status: 200, diff --git a/convex/contactMessages.ts b/convex/contactMessages.ts index 3233a0ff3..a17464465 100644 --- a/convex/contactMessages.ts +++ b/convex/contactMessages.ts @@ -6,6 +6,7 @@ export const submit = mutation({ name: v.string(), email: v.string(), organization: v.optional(v.string()), + phone: v.optional(v.string()), message: v.optional(v.string()), source: v.string(), }, @@ -14,6 +15,7 @@ export const submit = mutation({ name: args.name, email: args.email, organization: args.organization, + phone: args.phone, message: args.message, source: args.source, receivedAt: Date.now(), diff --git a/convex/schema.ts b/convex/schema.ts index dca2488d9..039ce88c6 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -18,6 +18,7 @@ export default defineSchema({ name: v.string(), email: v.string(), organization: v.optional(v.string()), + phone: v.optional(v.string()), message: v.optional(v.string()), source: v.string(), receivedAt: v.number(), diff --git a/pro-test/src/App.tsx b/pro-test/src/App.tsx index b2cc333c7..768e13b6b 100644 --- a/pro-test/src/App.tsx +++ b/pro-test/src/App.tsx @@ -1059,13 +1059,26 @@ const EnterprisePage = () => ( email: fd.get('email'), name: fd.get('name'), organization: fd.get('organization'), + phone: fd.get('phone'), message: fd.get('message'), source: 'enterprise-contact', website: honeypot, turnstileToken, }), }); - if (!res.ok) throw new Error(); + const errorEl = form.querySelector('[data-form-error]') as HTMLElement | null; + if (!res.ok) { + const data = await res.json().catch(() => ({})); + if (res.status === 422 && errorEl) { + errorEl.textContent = data.error || t('enterpriseShowcase.workEmailRequired'); + errorEl.classList.remove('hidden'); + btn.textContent = origText; + btn.disabled = false; + return; + } + throw new Error(); + } + if (errorEl) errorEl.classList.add('hidden'); btn.textContent = t('enterpriseShowcase.contactSent'); btn.className = btn.className.replace('bg-wm-green', 'bg-wm-card border border-wm-green text-wm-green'); } catch { @@ -1083,7 +1096,11 @@ const EnterprisePage = () => (
- + +
+ + +