| import { mutation } from "./_generated/server"; |
| import { v, ConvexError } from "convex/values"; |
| import { isCorporateDomain } from "./lib/emailDomain"; |
|
|
| |
| |
| |
| |
| const MAX_NAME = 500; |
| const MAX_EMAIL = 254; |
| const MAX_ORG = 500; |
| const MAX_PHONE = 30; |
| const MAX_MESSAGE = 2000; |
| const MAX_SOURCE = 100; |
|
|
| const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |
|
|
| |
| |
| |
| |
| const PER_EMAIL_WINDOW_MS = 60 * 60 * 1000; |
| const PER_EMAIL_LIMIT = 5; |
|
|
| |
| |
| |
| const STRICT_CONTROL_RE = /[\x00-\x1F\x7F]/g; |
| |
| |
| |
| |
| const MULTILINE_CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F]/g; |
|
|
| function clip( |
| value: string | undefined, |
| max: number, |
| opts: { preserveNewlines?: boolean } = {}, |
| ): string | undefined { |
| if (value === undefined) return undefined; |
| const re = opts.preserveNewlines ? MULTILINE_CONTROL_RE : STRICT_CONTROL_RE; |
| const cleaned = value.replace(re, "").trim(); |
| if (cleaned.length === 0) return undefined; |
| return cleaned.slice(0, max); |
| } |
|
|
| export const submit = mutation({ |
| args: { |
| name: v.string(), |
| email: v.string(), |
| organization: v.optional(v.string()), |
| phone: v.optional(v.string()), |
| message: v.optional(v.string()), |
| source: v.string(), |
| }, |
| handler: async (ctx, args) => { |
| |
| |
| |
| 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(); |
|
|
| |
| |
| 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), |
| ) |
| .take(PER_EMAIL_LIMIT + 1); |
|
|
| if (recent.length >= PER_EMAIL_LIMIT) { |
| throw new ConvexError({ |
| kind: "rate_limited", |
| message: "Too many recent submissions for this email; try again later.", |
| }); |
| } |
|
|
| await ctx.db.insert("contactMessages", { |
| name, |
| email, |
| organization, |
| phone, |
| message, |
| source, |
| receivedAt: Date.now(), |
| normalizedEmail, |
| }); |
| return { status: "sent" as const }; |
| }, |
| }); |
|
|