{"record":{"id":"6fd434d7a9a3622c","repo":"koala73/worldmonitor","slug":"valid-email-is-required","errorCode":null,"errorMessage":"Valid email is required","messagePattern":"Valid email is required","errorType":"exception","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/contactMessages.ts","lineNumber":69,"sourceCode":"    organization: v.optional(v.string()),\n    phone: v.optional(v.string()),\n    message: v.optional(v.string()),\n    source: v.string(),\n  },\n  handler: async (ctx, args) => {\n    // Length / shape validation. Reject obviously-bogus input before\n    // it reaches the table — also a defence against prompt-injection\n    // payloads enormous enough to trip downstream LLM cost.\n    const name = clip(args.name, MAX_NAME);\n    const email = clip(args.email, MAX_EMAIL);\n    const organization = clip(args.organization, MAX_ORG);\n    const phone = clip(args.phone, MAX_PHONE);\n    const message = clip(args.message, MAX_MESSAGE, { preserveNewlines: true });\n    const source = clip(args.source, MAX_SOURCE) ?? \"unknown\";\n\n    if (!name) throw new ConvexError(\"Name is required\");\n    if (!email || !EMAIL_RE.test(email)) {\n      throw new ConvexError(\"Valid email is required\");\n    }\n    if (!isCorporateDomain(email)) {\n      throw new ConvexError({\n        kind: \"FREE_EMAIL_NOT_ALLOWED\",\n        message: \"Please use a corporate email address.\",\n      });\n    }\n\n    const normalizedEmail = email.toLowerCase();\n\n    // Throttle: cap recent submissions per email. Index lookup keeps this O(matches),\n    // which the limit caps at PER_EMAIL_LIMIT + 1.\n    const windowStart = Date.now() - PER_EMAIL_WINDOW_MS;\n    const recent = await ctx.db\n      .query(\"contactMessages\")\n      .withIndex(\"by_normalized_email_received\", (q) =>\n        q.eq(\"normalizedEmail\", normalizedEmail).gte(\"receivedAt\", windowStart),\n      )","sourceCodeStart":51,"sourceCodeEnd":87,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/contactMessages.ts#L51-L87","documentation":"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\"`.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Validate email shape client-side with the same regex before calling the mutation: `/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/`.","Ensure the form input has `required` and `type=\"email\"` and disable submit until non-empty.","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.","Branch on `err.data === \"Valid email is required\"` in the catch to show a field-level message rather than a generic toast."],"exampleFix":"// before\nawait convex.mutation(api.contactMessages.submit, { name, email: emailInput.value, source });\n\n// after\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst cleaned = emailInput.value.replace(/[\\x00-\\x1F\\x7F]/g, \"\").trim();\nif (!EMAIL_RE.test(cleaned)) {\n  setFieldError(\"email\", \"Enter a valid email address.\");\n  return;\n}\nawait convex.mutation(api.contactMessages.submit, { name, email: cleaned, source });","handlingStrategy":"validation","validationCode":"const EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nfunction cleanEmail(raw: string): string | null {\n  const cleaned = raw.replace(/[\\x00-\\x1F\\x7F]/g, \"\").trim();\n  return EMAIL_RE.test(cleaned) ? cleaned : null;\n}\n// before calling the mutation:\nconst email = cleanEmail(input);\nif (!email) { showFieldError(\"Enter a valid email.\"); return; }","typeGuard":"function isValidContactEmail(raw: unknown): raw is string {\n  return typeof raw === \"string\" && /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(raw.replace(/[\\x00-\\x1F\\x7F]/g, \"\").trim());\n}","tryCatchPattern":"try {\n  await convex.mutation(api.contactMessages.submit, { name, email, source });\n} catch (err) {\n  if (err.data === \"Valid email is required\") setFieldError(\"email\", \"Enter a valid email.\");\n  else throw err;\n}","preventionTips":["Add `required type=email` and the shape regex on the input client-side.","Strip C0 controls before validation to match the server's `clip()`.","Disable submit until the email field passes the regex.","Branch on the exact string `err.data` for field-level messaging."],"tags":["validation","convex","contact-form","email"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}