koala73/worldmonitor · error · ConvexError

Valid email is required

Error message

Valid email is required

What it means

Thrown by the `submit` contact-message Convex mutation after `clip()` strips C0 controls and trims the `email` field. Fires when the cleaned email is falsy (empty/whitespace-only) OR fails the basic shape regex `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` (local-part@domain.tld). This is the first of three sequential email gates (shape -> corporate-domain -> throttle) and rejects obviously-bogus input before it can reach the table or downstream LLM cost. It is a plain-string ConvexError, so on the client `err.data === "Valid email is required"`.

Source

Thrown at convex/contactMessages.ts:69

    organization: v.optional(v.string()),
    phone: v.optional(v.string()),
    message: v.optional(v.string()),
    source: v.string(),
  },
  handler: async (ctx, args) => {
    // Length / shape validation. Reject obviously-bogus input before
    // it reaches the table — also a defence against prompt-injection
    // payloads enormous enough to trip downstream LLM cost.
    const name = clip(args.name, MAX_NAME);
    const email = clip(args.email, MAX_EMAIL);
    const organization = clip(args.organization, MAX_ORG);
    const phone = clip(args.phone, MAX_PHONE);
    const message = clip(args.message, MAX_MESSAGE, { preserveNewlines: true });
    const source = clip(args.source, MAX_SOURCE) ?? "unknown";

    if (!name) throw new ConvexError("Name is required");
    if (!email || !EMAIL_RE.test(email)) {
      throw new ConvexError("Valid email is required");
    }
    if (!isCorporateDomain(email)) {
      throw new ConvexError({
        kind: "FREE_EMAIL_NOT_ALLOWED",
        message: "Please use a corporate email address.",
      });
    }

    const normalizedEmail = email.toLowerCase();

    // Throttle: cap recent submissions per email. Index lookup keeps this O(matches),
    // which the limit caps at PER_EMAIL_LIMIT + 1.
    const windowStart = Date.now() - PER_EMAIL_WINDOW_MS;
    const recent = await ctx.db
      .query("contactMessages")
      .withIndex("by_normalized_email_received", (q) =>
        q.eq("normalizedEmail", normalizedEmail).gte("receivedAt", windowStart),
      )

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Validate email shape client-side with the same regex before calling the mutation: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`.
  2. Ensure the form input has `required` and `type="email"` and disable submit until non-empty.
  3. If calling Convex directly (bypassing the edge handler), replicate `clip()` (strip `[\x00-\x1F\x7F]`, trim) before validation so control-char-only payloads are caught early.
  4. Branch on `err.data === "Valid email is required"` in the catch to show a field-level message rather than a generic toast.

Example fix

// before
await convex.mutation(api.contactMessages.submit, { name, email: emailInput.value, source });

// after
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const cleaned = emailInput.value.replace(/[\x00-\x1F\x7F]/g, "").trim();
if (!EMAIL_RE.test(cleaned)) {
  setFieldError("email", "Enter a valid email address.");
  return;
}
await convex.mutation(api.contactMessages.submit, { name, email: cleaned, source });
Defensive patterns

Strategy: validation

Validate before calling

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function cleanEmail(raw: string): string | null {
  const cleaned = raw.replace(/[\x00-\x1F\x7F]/g, "").trim();
  return EMAIL_RE.test(cleaned) ? cleaned : null;
}
// before calling the mutation:
const email = cleanEmail(input);
if (!email) { showFieldError("Enter a valid email."); return; }

Type guard

function isValidContactEmail(raw: unknown): raw is string {
  return typeof raw === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw.replace(/[\x00-\x1F\x7F]/g, "").trim());
}

Try / catch

try {
  await convex.mutation(api.contactMessages.submit, { name, email, source });
} catch (err) {
  if (err.data === "Valid email is required") setFieldError("email", "Enter a valid email.");
  else throw err;
}

Prevention

When it happens

Trigger: Calling `api.contactMessages.submit` with `email: ""`, `email: " "` (trimmed to empty), `email: "a"` (no @), `email: "a@b"` (no dot in domain), `email: "a@b." `, or a value composed entirely of stripped control chars (e.g. `"\x00\x01"`). Also fires if the field is omitted despite the `v.string()` schema (Convex rejects undefined at the schema layer with a different error, but an empty string reaches this check).

Common situations: Contact form submitted before the user typed an email; client-side validation disabled or bypassed; an automated/probing client POSTing raw args directly to the Convex mutation to skip the edge handler (`server/worldmonitor/leads/v1/submit-contact.ts`) which enforces the same bound; copy-paste introducing a leading/trailing space that the regex tolerates but a control char that `clip` strips to empty does not.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/6fd434d7a9a3622c. Report an issue: GitHub.