Replace EmailJS lead forms with server-side Twenty CRM integration
Problem
The three lead-capture forms in dimo-org (Footer newsletter signup, ChatBot
inquiry, Pricing enterprise modal) call EmailJS directly from the browser using
a public key baked into the client bundle. Anti-spam checks (honeypot, minimum
fill time, submission cooldown) live entirely in client-side JS in
src/utils/antiSpam.ts, so they're trivially bypassed by anyone calling the
EmailJS API directly. Leads land in an inbox as loosely-parsed email rather than
structured CRM records.
We're replacing this with a server-side integration into Twenty CRM (Twenty
Cloud, https://api.twenty.com/rest), which gives structured lead records
(Person + Opportunity) and moves anti-spam enforcement from bypassable client JS
to the server, raising the cost of casual/naive abuse.
Architecture
One new serverless function, api/lead.ts, deployed alongside the static
Docusaurus build (Vercel supports /api functions for any framework with zero
extra build config). All three forms POST to this single endpoint instead of
calling EmailJS directly.
A single shared endpoint (rather than one per form) keeps validation and
Twenty-mapping logic in one place, mirroring the existing shared antiSpam.ts
utility. Requests are distinguished by a source field.
Credentials (TWENTY_API_KEY, TWENTY_API_URL) are Vercel environment
variables, server-side only — never shipped to the client.
Request/response contract
POST /api/lead
Content-Type: application/json
{
"name": string,
"email": string,
"phone"?: string,
"company"?: string,
"details": string,
"products": string,
"source": "newsletter" | "chatbot" | "enterprise",
"honeypot": string, // must be empty
"formStartedAt": number // client timestamp (ms) when the form was first rendered/focused
}
200 { "ok": true }
400 { "error": string } // validation or spam-check failure
502 { "error": string } // Twenty API failure
Server-side logic (api/lead.ts)
-
Validate
- Reject (400) if
honeypotis non-empty. - Reject (400) if
Date.now() - formStartedAt < MIN_FILL_TIME_MS(reuse the existing constant fromantiSpam.ts). - Reject (400) if
emailfails the existing email regex. - This moves anti-spam enforcement from bypassable client JS to the server,
which raises the cost of casual/naive abuse (a browser-based bot or someone
hand-editing form values) — it does not stop a scripted attacker who crafts
the POST body directly, since
formStartedAtis still client-supplied. A rate limit (e.g. via Vercel Firewall) would be a stronger next layer, out of scope for this change.
- Reject (400) if
-
Upsert Person
GET /rest/people?filter=emails.primaryEmail[eq]:<email>to look for an existing match.- If none found,
POST /rest/peopleto create one. - Exact field names (e.g.
emails.primaryEmailvs. a workspace-customized field) depend on the target Twenty workspace's schema (Settings → API & Webhooks → API docs). Build against Twenty's documented defaults; adjust field names against the live workspace during implementation/testing.
-
Create Opportunity
POST /rest/opportunities, linked to the person viapointOfContactId.name: derived fromsource+ company or email (e.g."Enterprise Inquiry – Acme Corp","Newsletter Signup – [email protected]").stage: default new-lead stage (workspace default).
-
Return
200 { ok: true }on success. On any Twenty API failure, log details server-side (Vercel function logs) and return502with a generic error message — no internal details leaked to the client.
Client changes
src/theme/Footer/index.tsx,src/components/ChatBot/ChatBot.tsx,src/pages/pricing.tsx: replaceemailjs.send(...)calls withfetch('/api/lead', { method: 'POST', body: JSON.stringify(...) }). Existing form state, honeypot field, fill-time tracking, and cooldown UX stay as-is — they now double as UX-level friction in addition to the real server-side enforcement.- Remove
@emailjs/browserfrompackage.json. - Remove
emailjsServiceId/emailjsTemplateId/emailjsPublicKeywiring fromdocusaurus.config.ts. vercel.json: drophttps://api.emailjs.comfrom the CSPconnect-srcdirective (no longer needed; requests now go to same-origin/api/lead).
Error handling
- Network/fetch failure or non-2xx response from
/api/lead: surface the existing per-form error UI (each form already has an error state for the EmailJS failure path — reuse it). - Add a client-side fetch timeout (~10s) so a hung request doesn't leave the form stuck in a submitting state indefinitely.
Testing
No test framework exists in this repo currently (confirmed against
package.json), so verification is manual:
vercel devlocally;curlthe endpoint directly with a valid payload, a honeypot-filled payload, and a too-fast-fill payload to confirm the 200/400 split.- Exercise all three forms manually in a browser: happy path, honeypot rejection, and fill-time rejection.
- Confirm a real submission creates the expected Person + Opportunity records in the Twenty workspace before calling this done.
Out of scope
- Changing the anti-spam cooldown mechanism (still client-side localStorage) — not part of this change.
- The separate Formcrafts-based "Contact Us" flow on the pricing page (unrelated to EmailJS/Twenty).