Detecting a contact form that goes nowhere
A form that posts to nothing is a business quietly losing enquiries. Finding that from the outside, without ever submitting one, is harder than it sounds.
A contact form that posts nowhere is a business losing enquiries without knowing it. Visitors fill it in, see a success message, and never hear back. The owner concludes the website does not generate leads.
ColdDeck looks for this from the outside, because it is the single most useful thing you can tell a small business about their own site. The constraint that makes it interesting: we will not submit the form. Sending fake enquiries to find out whether enquiries arrive is exactly the behaviour the whole system exists not to have.
The honest version of this check is strictly weaker than the dishonest one. That is the trade, and it is the right one.
What can be seen without submitting
Reading the markup gets you further than expected.
No action and no submit handler. A <form> with no action attribute
posts to the current URL. If nothing on that URL handles a POST, the data goes
nowhere. Combined with no onsubmit, no listener bound in any loaded script, and
no form library on the page, this is close to conclusive.
action="#" or action="". Almost always a template placeholder nobody
filled in. In practice this is the highest-precision signal available.
mailto: action. Legal, and broken for most visitors — it depends on a
configured desktop mail client. On mobile it usually does nothing at all. Not
strictly a fault, and worth flagging.
A form handler pointing at a service that no longer resolves. Third-party form endpoints get abandoned when a free tier lapses. A DNS lookup on the action host, with no request sent, answers this.
type FormFinding =
| { kind: "no_action_no_handler"; confidence: "high" }
| { kind: "placeholder_action"; value: string; confidence: "high" }
| { kind: "mailto_action"; confidence: "medium" }
| { kind: "action_host_unresolvable"; host: string; confidence: "high" }
| { kind: "no_form_at_all"; confidence: "high" }
function inspectForm(form: Element, page: LoadedPage): FormFinding | null {
const action = form.getAttribute("action")
if (action === "#" || action === "") {
return { kind: "placeholder_action", value: action, confidence: "high" }
}
if (action?.startsWith("mailto:")) {
return { kind: "mailto_action", confidence: "medium" }
}
if (!action && !hasSubmitHandler(form, page)) {
return { kind: "no_action_no_handler", confidence: "high" }
}
return null
}
The part that required a headless browser
Static HTML analysis produces false positives on any site built with a
JavaScript framework, and small business sites increasingly are. A React form
frequently has no action at all — submission is a bound handler, and the markup
looks broken while the form works perfectly.
So hasSubmitHandler cannot be a regex over source. It has to run the page and
ask the DOM:
// In a headless browser, after the page has settled.
function hasSubmitHandler(form: HTMLFormElement): boolean {
// Listeners attached directly to the form.
if (getEventListeners(form).submit?.length) return true
// A submit button with its own click handler — common in React.
const submits = form.querySelectorAll('button[type="submit"], input[type="submit"]')
for (const b of submits) {
if (getEventListeners(b).click?.length) return true
}
// Delegated handling at the document level. Cannot be attributed to
// this specific form, so treat its presence as "cannot determine"
// rather than as either answer.
return documentHasDelegatedSubmitListener()
}
That last branch is the important one. Delegated event handling is genuinely ambiguous from the outside, and the correct output is unknown rather than a guess in either direction. It became a fourth state, not a coin flip.
The false positives we accepted
Three cases stayed wrong, and each was a deliberate decision rather than a bug we failed to fix.
Forms behind a consent banner. Some sites do not initialise form handlers until cookie consent is given. The crawler does not click consent banners — it is not a person, and clicking "I agree" on someone's behalf is a small dishonesty we did not want in the system. Those sites report unknown.
Forms requiring interaction to render. A form inside a modal opened by a button. The crawler reads a few pages at roughly human pace and does not hunt through interactive states.
Server-side frameworks with no visible action. Rare, and indistinguishable from a genuinely broken form without submitting. Unknown.
The resulting distribution, roughly: a clear majority resolve to working or broken, and a meaningful minority land in unknown. We report the definite ones and stay silent about the rest.
Why unknown is a real output
The temptation with a scoring system is to force every signal to a value, because unknown contributes nothing to a score and feels like a failure.
But this signal ends up in a sentence in an email to a business owner. If it says their contact form is broken and it is not, the email is worse than useless — it is a stranger being confidently wrong about something they can check in ten seconds. The recipient's correct response is to disregard everything else in the message.
So unknown is absent from the score entirely, per the design in a rule pack, not a model: a signal is present or it is not, and uncertain signals do not partially count. A prospect with an unknown form is scored on everything else and the email does not mention it.
What it is worth
Of the signals ColdDeck detects — no online booking, no chat, no mobile viewport, no SSL, broken form — the broken form is the one that most reliably produces a reply. It is specific, it is checkable, it is costing the owner money right now, and it is nearly always news to them.
Which is the general lesson from building it: the value is in the specificity, and specificity is only worth anything if it is right. A signal detected enthusiastically and wrongly is worse than not detecting it, and that asymmetry is what justified accepting a weaker check to keep it honest.
ColdDeck is ours, and it is also our own outbound engine. More in why our crawler is deliberately slow, a rule pack, not a model, and the case studies.