/** * Cloudflare Pages Function: /api/submit-intake (hardened) * * Required env: M365_TENANT_ID, M365_CLIENT_ID, M365_CLIENT_SECRET, M365_SENDER * Optional: INTAKE_DEST, TURNSTILE_SECRET * * Hardening: 64 KB body cap; honeypot drop; optional Turnstile verification; * server-side email-format validation and string/array clamping; generic * client errors with internal detail logged server-side only. */ const MAX_BODY_BYTES = 64 * 1024; export async function onRequestPost(context) { const { request, env } = context; const ctype = (request.headers.get('content-type') || '').split(';')[0].trim(); if (ctype !== 'application/json') { return jsonResponse({ error: 'Unsupported content type' }, 415); } const rawBody = await request.text(); if (rawBody.length > MAX_BODY_BYTES) { return jsonResponse({ error: 'Payload too large' }, 413); } let payload; try { payload = JSON.parse(rawBody); } catch { return jsonResponse({ error: 'Invalid request body' }, 400); } const rawState = (payload && typeof payload.state === 'object' && payload.state) || {}; // Honeypot: humans never fill website_hp. If present, accept silently, send nothing. if (clampString(rawState.website_hp, 10)) { return jsonResponse({ ok: true }); } // Optional Turnstile verification (enforced only if TURNSTILE_SECRET is set). if (env.TURNSTILE_SECRET) { const ok = await verifyTurnstile(env.TURNSTILE_SECRET, payload.turnstileToken, request.headers.get('cf-connecting-ip')); if (!ok) return jsonResponse({ error: 'Verification failed' }, 403); } const state = sanitizeState(rawState); const estimate = sanitizeEstimate(payload.estimate); const receiptId = clampString(payload.receiptId, 40); if (!state.contact_email && !state.legal_name) { return jsonResponse({ error: 'Missing required fields' }, 400); } if (state.contact_email && !isValidEmail(state.contact_email)) { return jsonResponse({ error: 'Invalid email address' }, 400); } const tenantId = env.M365_TENANT_ID; const clientId = env.M365_CLIENT_ID; const clientSecret = env.M365_CLIENT_SECRET; const sender = env.M365_SENDER; const destination = env.INTAKE_DEST || sender; if (!tenantId || !clientId || !clientSecret || !sender) { console.error('Intake: email service env vars missing'); return jsonResponse({ error: 'Email service unavailable' }, 503); } const companyName = (state.legal_name || 'Unnamed Company').trim(); const subject = `${companyName} Intake Form`; const html = buildEmailHTML(state, estimate, receiptId); try { const tokenResp = await fetch( `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, scope: 'https://graph.microsoft.com/.default', grant_type: 'client_credentials', }), } ); if (!tokenResp.ok) { console.error('Intake: token failed', tokenResp.status, await safeText(tokenResp)); return jsonResponse({ error: 'Email service unavailable' }, 502); } const tokenJson = await tokenResp.json(); const accessToken = tokenJson.access_token; if (!accessToken) { console.error('Intake: no access token returned'); return jsonResponse({ error: 'Email service unavailable' }, 502); } const message = { message: { subject: subject, body: { contentType: 'HTML', content: html }, toRecipients: [{ emailAddress: { address: destination } }], }, saveToSentItems: 'true', }; if (state.contact_email) { message.message.replyTo = [{ emailAddress: { address: state.contact_email } }]; } const sendResp = await fetch( `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(message), } ); if (!sendResp.ok) { console.error('Intake: sendMail failed', sendResp.status, await safeText(sendResp)); return jsonResponse({ error: 'Could not send message' }, 502); } return jsonResponse({ ok: true }); } catch (err) { console.error('Intake: send error', String(err)); return jsonResponse({ error: 'Could not send message' }, 502); } } // ---- validation / sanitization helpers ---- function clampString(v, max) { if (v == null) return ''; return String(v).slice(0, max); } function isValidEmail(v) { return typeof v === 'string' && v.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); } function sanitizeState(input) { const out = {}; if (!input || typeof input !== 'object') return out; let count = 0; for (const [k, v] of Object.entries(input)) { if (count++ >= 60) break; if (k === 'website_hp') continue; const key = clampString(k, 64); if (Array.isArray(v)) { out[key] = v.slice(0, 50).map((item) => clampString(item, 200)); } else if (v != null && typeof v !== 'object') { out[key] = clampString(v, 2000); } } return out; } function sanitizeEstimate(input) { if (!input || typeof input !== 'object') return {}; const out = { amount: clampString(input.amount, 80), range: clampString(input.range, 300) }; if (Array.isArray(input.breakdown)) { out.breakdown = input.breakdown.slice(0, 30).map((b) => ({ label: clampString(b && b.label, 120), value: clampString(b && b.value, 80), })); } return out; } async function verifyTurnstile(secret, token, ip) { if (!token) return false; try { const form = new URLSearchParams({ secret, response: String(token) }); if (ip) form.set('remoteip', ip); const resp = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form, }); const data = await resp.json(); return data.success === true; } catch { return false; } } async function safeText(resp) { try { return await resp.text(); } catch { return ''; } } function jsonResponse(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, }); } function esc(v) { if (v == null) return ''; return String(v) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function row(label, value) { if (value === undefined || value === null || value === '') return ''; const val = Array.isArray(value) ? value.join(', ') : value; return ` ${esc(label)} ${esc(val)} `; } function section(title, rowsHtml) { if (!rowsHtml.replace(/\s/g, '')) return ''; return `

${esc(title)}

${rowsHtml}
`; } // ---- value -> label maps (mirror intake.html option labels) ---- const M_EMP = { micro:'1–25 employees', small:'26–100 employees', 'mid-small':'101–250 employees', mid:'251–500 employees', large:'501–1000 employees', enterprise:'1000+ employees' }; const M_LEVEL = { L1:'Level 1 (FCI)', L2:'Level 2 (CUI)', L3:'Level 3 (Expert)', unsure:'Not yet determined' }; const M_POSTURE = { none:'Not started', planning:'Initial planning', 'self-assessed':'Self-assessed', sprs:'SPRS score posted', 'draft-docs':'Draft SSP/POA&M', 'active-c3pao':'Active C3PAO engagement' }; const M_DFARS = { '7012':'DFARS 252.204-7012 (Safeguarding CDI)', '7021':'DFARS 252.204-7021 (CMMC Level)', '7997':'DFARS 252.240-7997 (DoD Assessment)', '7025':'DFARS 252.204-7025 (Notice of CMMC Level)', far21:'FAR 52.240-93 (Basic Safeguarding)', unknown:'Needs contract review' }; const M_CUI = { def:'Defense', export:'Export Control (ITAR/EAR)', cti:'Controlled Technical Information', pii:'Privacy / PII', proc:'Procurement & Acquisition', ci:'Critical Infrastructure', nuclear:'Nuclear', other:'Other / unsure' }; const M_CLOUD = { 'm365-commercial':'M365 Commercial', 'm365-gcc':'M365 GCC', 'm365-gcch':'M365 GCC High', gws:'Google Workspace', onprem:'On-premises', hybrid:'Hybrid', other:'Other' }; const M_REMOTE = { none:'None', some:'Some', most:'Most', all:'All' }; const M_BYOD = { yes:'Yes', no:'No', limited:'Limited' }; const M_ENCLAVE = { yes:'Yes', no:'No', planning:'Planning' }; const M_YN = { yes:'Yes', no:'No', unsure:'Unsure' }; const M_MOTIV = { contract:'Contract requirement', prime:'Prime / customer mandate', growth:'Growth / new pursuits', governance:'Governance / risk', incident:'Post-incident' }; const M_DATE = { urgent:'Within 6 months', near:'6–12 months', mid:'12–18 months', far:'18+ months', undetermined:'Undetermined' }; const M_ENG = { readiness:'Readiness Assessment', full:'Full Implementation', mock:'Mock Pre-Assessment', ssp:'SSP & POA&M Authoring', retainer:'Ongoing Compliance Retainer', unsure:'To be determined' }; function lbl(v, map) { if (v == null || v === '') return ''; return map[v] || v; } function lblArr(v, map) { if (!Array.isArray(v)) return v == null ? '' : (map[v] || v); return v.map((x) => map[x] || x).join(', '); } function buildEmailHTML(state, estimate, receiptId) { const company = state.legal_name || 'Unnamed Company'; const submitted = new Date().toISOString(); return `
P R A E D Y N
Praesidium in omnibus
— Engagement Intake

${esc(company)}

Submitted ${esc(submitted)}${receiptId ? ` · Receipt ${esc(receiptId)}` : ''}

Estimated Investment
${esc(estimate.amount || 'To be scoped')}
${estimate.range ? `
Planning range: ${esc(estimate.range)}
` : ''}
${section('Company', [ row('Legal entity', state.legal_name), row('DBA', state.dba), row('Industry', state.industry), row('Headquarters', state.hq_state), row('Employees', lbl(state.employees, M_EMP)), row('NAICS', state.naics), row('CAGE code', state.cage), row('UEI', state.uei), ].join(''))} ${section('CMMC level & contracts', [ row('Target CMMC level', lbl(state.target_level, M_LEVEL)), row('Current posture', lbl(state.posture, M_POSTURE)), row('Contract clauses', lblArr(state.dfars, M_DFARS)), row('Active DoD contracts', state.contracts_active), row('Primes / customers', state.primes), ].join(''))} ${section('CUI & data profile', [ row('Handles CUI', lbl(state.handles_cui, M_YN)), row('CUI categories', lblArr(state.cui_cat, M_CUI)), row('All staff U.S. persons', lbl(state.us_persons, M_YN)), row('CUI enclave', lbl(state.enclave, M_ENCLAVE)), row('Network diagram', lbl(state.net_diagram, M_YN)), row('Data-flow diagram', lbl(state.data_flow, M_YN)), ].join(''))} ${section('Environment', [ row('Cloud / IT platform', lbl(state.cloud, M_CLOUD)), row('User accounts', state.users), row('Endpoints in scope', state.endpoints), row('Servers', state.servers), row('Network devices', state.netdev), row('Mobile devices', state.mobile), row('Physical sites', state.sites), row('Remote work', lbl(state.remote, M_REMOTE)), row('BYOD', lbl(state.byod, M_BYOD)), row('MSP / MSSP', state.msp), row('OT / ICS in scope', lbl(state.ot_ics, M_YN)), ].join(''))} ${section('Goals & engagement', [ row('Primary drivers', lblArr(state.motivator, M_MOTIV)), row('Target assessment date', lbl(state.target_date, M_DATE)), row('Current SPRS score', state.sprs_current), row('Engagement type', lbl(state.engagement, M_ENG)), ].join(''))} ${section('Contact', [ row('Name', state.contact_name), row('Title', state.contact_title), row('Email', state.contact_email), row('Phone', state.contact_phone), row('Consent to contact', state.consent ? 'Yes' : ''), ].join(''))} ${state.notes ? `

Additional notes

${esc(state.notes)}

` : ''} ${estimate.breakdown && estimate.breakdown.length ? `

Estimate breakdown

${estimate.breakdown.map(b => ` `).join('')}
${esc(b.label)} ${esc(b.value)}
` : ''}
Planning estimate only — not a binding quote. Final investment is locked after a full-scope scoping conversation and is documented in a signed Statement of Work.
Praedyn LLC · Colorado, USA · praedyn.com
`; }