Notifio is a desktop app that watches rental search pages and emails you when a new listing appears, so support mail matters more than usual: somebody writing in has usually already paid, and is usually in a hurry. The form they write from lives at the bottom of the help page, under the FAQ and the Gmail filter instructions, and it has no captcha on it.
That was a decision, not an oversight, and the honest version of it is that the form has four small defences instead of one big one, and only two of them are about abuse at all.
The whole route
export async function POST(req: NextRequest) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
const { success } = await ratelimit.limit(`contact:${ip}`);
if (!success) {
return NextResponse.json({ error: "Too many requests." }, { status: 429 });
}
let payload: ContactPayload;
try {
payload = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const name = (payload.name ?? "").trim();
const email = (payload.email ?? "").trim().toLowerCase();
const subject = (payload.subject ?? "").trim();
const message = (payload.message ?? "").trim();
...
}
After that the route is four guards in a row: the JSON parse, the required fields, the shape of the email address, and the length of the message.
Defence one: a rate limit that was built for something else
// 20 requests per 10 seconds, shared by validate and notify.
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "10 s"),
analytics: false,
prefix: "notifio:rl",
});
This limiter exists because the desktop app calls our licence check and our alert endpoint on a schedule, and twenty requests per ten seconds is the shape of that traffic. The contact form reuses it with a different key, contact:<ip>, so it gets its own counter but somebody else's budget.
Twenty messages in ten seconds is not a spam defence. I want to be clear about that rather than describe it as one. What the key does buy is that a broken client cannot loop, and a single address cannot sit there posting thousands of messages per minute, which is the failure mode that costs money rather than attention.
The x-forwarded-for read is first value, trimmed, falling back to the string "unknown". Everybody behind a NAT shares a counter. For a rental tool that is a real case: I wrote separately about a student house being one IP address and what we count instead on the endpoints where it matters. On a contact form, sharing is fine, because the limit is loose enough that two flatmates writing in on the same afternoon will never notice it.
Defence two: four validations, and the one the browser cannot do
if (!name || !email || !message) {
return NextResponse.json(
{ error: "name, email and message are required." },
{ status: 400 }
);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "Invalid email address." }, { status: 400 });
}
if (message.length > 5000) {
return NextResponse.json({ error: "Message is too long." }, { status: 400 });
}
Two of those three are repeats of something the markup already does. The client has required on name, email and message, and type="email" on the email field, and the browser enforces both before the submit handler ever runs.
The repetition is not redundancy, because the two layers protect different things. required protects the person filling in the form from a wasted round trip. The server check protects the mailbox from anything that did not come from that form, which is everything that will ever actually be a problem.
The 5000 character cap, has no counterpart in the markup at all, and that is the one I would add to the UI if I changed anything here. Right now somebody pasting a 20,000 character log file gets a "Message is too long" error after they have already pressed send. A character counter under the textarea would be kinder, and it is still not a defence: it is the server cap that means a single POST cannot carry a megabyte into an email body.
Subject is the only optional field, and it is the only one whose absence changes the shape of the output:
const emailSubject = subject
? `Notifio Contact: ${subject}`
: `Notifio Contact from ${name}`;
An email with no subject is almost unsearchable in a mailbox a year later, so the route refuses to produce one.
Defence three: an escape function, written out by hand
The forwarded message is an HTML email, built as a template literal with inline styles, and every value from the form goes through this:
function escHtml(str: string): string {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Four replacements, ampersand first, which is the detail that makes the rest correct: escape the angle brackets before the ampersand and you turn < into < and then into &lt;, and the recipient reads the escape sequence instead of the character.
The reason this matters in an email rather than in a page is worth saying out loud, because "it is only an email to myself" is a tempting excuse. The message arrives in a real mail client, and mail clients render HTML. Markup in a message body would mean a sender controlling how their own support request looks in my inbox: a bolded fake "verified customer" line, a link whose text says one thing and whose href says another. Nothing dramatic, and nothing I want either.
String(str) wrapping a value already typed as string is belt and braces for a payload that arrived as JSON, where a field typed string? can turn up as a number, an array or null at runtime.
Defence four: the destination is a mailbox
const { success: sent, error } = await sendEmail({
from: EmailAddresses.NOREPLY,
to: "dan.pertu@gmail.com",
replyTo: email,
subject: emailSubject,
html,
text,
});
from is our own no-reply address, because the message is sent by our domain and has to pass our own SPF and DKIM. replyTo is whatever the visitor typed, so hitting reply in the mail client answers the customer rather than the robot. The footer of the email says so, because a forwarded message that looks like a notification gets read like one:
Reply directly to this email to respond to {name}.
This is also the last line of defence against the spam a captcha would have caught, and it is the reason the missing captcha is tolerable: whatever gets through lands in a mailbox that has had two decades of spam filtering poured into it, and gets deleted in the same gesture as everything else. A captcha in front of the form would stop some of that mail arriving, at the cost of every real customer solving a puzzle while annoyed. The trade only makes sense once the volume is real.
Every inbound message ends up in the same place through a different door, by the way: mail sent straight to our published addresses arrives as a webhook, gets parsed and forwarded with the same Reply-To trick, which I wrote about in our support inbox is a webhook.
The client is a four-state machine and one disappearing form
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
"idle"
);
sending disables the button and changes its label to "Sending…". error renders the server's message verbatim under the fields, with everything the visitor typed still in place. sent does something more aggressive: it replaces the entire form with a confirmation panel.
if (status === "sent") {
return (
<div className="rounded-lg border border-[#262626] bg-[#111] px-6 py-8 text-center">
<p className="text-[14px] font-semibold text-[#e5e5e5]">Message sent</p>
<p className="mt-2 text-[13px] text-[#737373]">
We'll get back to you as soon as possible.
</p>
</div>
);
}
Unmounting the form is the point. A form that is still sitting there with the text in it after a successful send invites a second press, and a second press on a support form is a duplicate thread, not a retry. There is no "send another" button either, which is the right default for a page somebody reaches when something is wrong.
The error path keeps the text for the opposite reason: a failed send where the box empties itself is the one outcome nobody forgives.
What the client deliberately does not do is retry. A fetch that throws has an ambiguous outcome, because the request may have reached the route and sent the mail before the connection died, so an automatic retry is a coin flip between helping and duplicating. The visitor gets "Network error. Please try again." and the decision.
One more thing: it stays up when the database goes down
Notifio's site has a maintenance switch that answers 503 to anything needing the database. Three API routes are exempt, and the contact form is one of them:
const NO_DATABASE_ROUTES = ["/api/download", "/api/contact", "/api/webhooks/resend"];
The route touches Redis for the limit and Resend for the send, and never Postgres. Which means the one moment customers most want to tell us something is the one moment the form is guaranteed to work, and that is worth more than any of the four defences above.
If you want to see the form, it is under the FAQ on notifio.app/help, and the FAQ above it answers most of what people write in about. The app it supports watches fifteen rental portals, Kamernet and Pararius and Funda among them from your own machine.
Top comments (0)