I build marketing sites for clients. Almost every one has a contact form that emails the client's own inbox, so they hear directly from their customers. We're careful about blocking anything sent through those forms, because any message could be from a real customer.

Most of the spam that reaches those inboxes is the same kind of sales outreach, usually SEO pitches or offers of AI development services. Even with the usual defenses in place, a certain percentage of it still gets through, and my clients don't like getting it.

AI has also made these messages better at sounding like a real person wrote them. On our older sites, the polite, human-sounding vendor pitches were the ones that kept getting past.

Freek Van der Herten at Spatie wrote about sorting helpdesk mail with Jev, OpenRouter's decision model, in Detecting spam and auto-replies with Jev and the Laravel AI SDK. Jev is a small model that's very quick at making decisions and classifying things, and it turns out to be a really effective way to filter these messages.

So I took what I learned from his posts and adapted it to the marketing sites we build. We ask Jev one question about each message: is the sender trying to sell something to the business, or trying to hire it? Selling is spam. Someone asking for a quote, someone who wants something built and someone applying for a job all go through. When it's unclear, the message goes through too, because a stray pitch costs a lot less than a lost client.

The visitor shouldn't have to wait on the model, though. After a few quick checks, the form answers right away, and the Jev check and the email send run in the background with Cloudflare's ctx.waitUntil, which keeps the Worker going after the response is out. On our Laravel sites that would be a queued job. If Jev is slow to answer one day, the person filling out the form never notices.

Here's a simplified version of the code on our Astro site, where the contact form posts to a Cloudflare Worker:

// src/lib/spam.ts
const SPAM_MODEL = "your-classifier-model";
export const SPAM_THRESHOLD = 0.8; // example value: above this, the message is dropped
const SPAM_TIMEOUT_MS = 3000; // example value

const DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";

// One yes/no question. The model answers with the probability that it's true.
const SPAM_QUESTION = {
  type: "noul",
  instructions:
    "This message was sent through a business's contact form. Is it spam? " +
    "A sender trying to sell something to the business is spam. " +
    "A sender who wants to hire the business, or apply for a job, is not.",
  criteria: {
    true: "Unsolicited sales or marketing outreach, scams or phishing.",
    false: "A request for a quote or help with a project, a job applicant or a current client.",
  },
};

const isProbability = (value: unknown): value is number =>
  typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;

// The probability that the message is spam, or null if it couldn't be classified.
export async function spamProbability(message: string, apiKey: string): Promise<number | null> {
  try {
    const response = await fetch(DECISIONS_URL, {
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        model: SPAM_MODEL,
        state: { message },
        questions: { spam: SPAM_QUESTION },
      }),
      signal: AbortSignal.timeout(SPAM_TIMEOUT_MS),
    });
    if (!response.ok) return null;

    const result = await response.json();
    const probability = result.answers?.spam?.noul;
    return isProbability(probability) ? probability : null;
  } catch {
    return null; // timeout, network error or a response that isn't JSON
  }
}

// src/lib/contact.ts
import { SPAM_THRESHOLD, spamProbability } from "./spam";
import { sendToClientInbox } from "./email";

export function handleContact(message: string, env: Env, ctx: ExecutionContext) {
  // The visitor gets their answer now. Jev and the send run after the response.
  ctx.waitUntil(deliver(message, env));
  return { ok: true };
}

async function deliver(message: string, env: Env) {
  const score = await spamProbability(message, env.OPENROUTER_API_KEY);
  if (score !== null && score > SPAM_THRESHOLD) return; // spam: drop it

  await sendToClientInbox(message, env); // not spam, or the model failed
}

// worker/index.ts
import { handleContact } from "../src/lib/contact";

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const { message } = await request.json(); // after a few quick checks
    return Response.json(handleContact(message, env, ctx));
  },
};

If the model is down, times out or answers with something that isn't a probability, the message still sends.

Contact form Visitor gets success now, Jev runs in ctx.waitUntil JEV Is this a sales pitch? Yes Dropped No Model error or timeout Client inbox

We build some of these sites with Laravel and some with Astro, and we adapted the same approach to both. It has worked well on both.

Isaac Earl Avatar

Isaac Earl

// Lead Developer

Related Posts

Don't let your project stall

get a free consultation