DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our contact form mails from an address nobody reads and puts a stranger in Reply-To

The support form at the bottom of notifio.app/help is a Next.js route handler, a Resend call and about a hundred lines of email HTML. Notifio is a desktop app that watches rental search pages, sold once with no subscription, so support volume is low and the form is the whole system. There is no ticketing tool behind it.

That makes it a nice small object to look at, because every decision in it is visible and a few of them took a second attempt.

The headers are the interesting part

const { success: sent, error } = await sendEmail({
  from: EmailAddresses.NOREPLY,     // "Notifio <noreply@notifio.app>"
  to: OWNER_INBOX,
  replyTo: email,                   // whatever the visitor typed
  subject: emailSubject,
  html,
  text,
});
Enter fullscreen mode Exit fullscreen mode

The tempting version of this is from: email, so the message lands in my inbox looking as though the visitor sent it. Gmail would show their name, replying would work with no extra header, and the thread would look like an ordinary conversation.

It also would not arrive. notifio.app publishes SPF and DKIM records that authorise Resend to send as that domain, and nothing authorises Resend to send as somebody's Gmail address. Forging a From you do not control is the exact pattern domain authentication exists to catch, so the best case is a spam folder and the worst case is our sending reputation.

Reply-To has no such constraint. It is not authenticated, it is not used for delivery, and it is exactly the right place for an address that belongs to a third party. The From stays noreply@notifio.app, which is a mailbox nobody reads, and that is fine, because nobody is supposed to reply to it. I am.

So the email ends with a line that explains the mechanism to the only person who will read it:

`<p>Reply directly to this email to respond to ${escHtml(name)}.</p>`
Enter fullscreen mode Exit fullscreen mode

One small consequence: Reply-To carries an address that has not been verified in any way. Replying to a support message therefore means sending mail to a string a stranger typed, which is the normal situation for all support email and worth being conscious of anyway.

The escaping has exactly one job and does it in one place

Four fields arrive from a public form and three of them end up inside an HTML document:

function escHtml(str: string): string {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
Enter fullscreen mode Exit fullscreen mode

Nothing clever, and the ampersand replacement comes first so the others do not get double-encoded. Every interpolation in the HTML branch passes through it, including the ones you would not think of as risky, like the sender's own email address in the From block.

The plain text alternative is built separately and is not escaped:

const text = `New Notifio contact message\n\nFrom: ${name} <${email}>${
  subject ? `\nSubject: ${subject}` : ""
}\n\nMessage:\n${message}`;
Enter fullscreen mode Exit fullscreen mode

That is correct rather than an oversight. text/plain has no markup to break out of, and escaping it would put literal &lt; into a message a human has to read. The rule is that escaping belongs to the format, not to the data, which is why it is applied at the point of interpolation and not on the way in from req.json().

The multi-line message body is the case I like most here:

<p style="...white-space:pre-wrap;">${escHtml(message)}</p>
Enter fullscreen mode Exit fullscreen mode

The obvious way to preserve the line breaks someone typed is to replace \n with <br>. That means injecting HTML into a string immediately after escaping its HTML, and getting the order wrong is how <br> ends up visible in the email or how escaping gets skipped for the whole field. white-space: pre-wrap does the same job in CSS, so the escaped text stays escaped text all the way to the inbox. It is supported in every mail client I can test, and the degraded result if it were not is a wall of text rather than a security problem.

The route trusts nothing, including the parse

In order, before any mail is sent:

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 });
}
Enter fullscreen mode Exit fullscreen mode

The rate limit runs first, before the body is even read, because the cheapest request to reject is the one you have not parsed yet. The key is namespaced contact: so it shares a Redis instance with the licence check without sharing a budget, which matters because those two endpoints have wildly different legitimate rates. The licence check has its own history on that subject, which I wrote up in a student house is one IP address.

x-forwarded-for can hold a list, and the first entry is the client as the edge saw it. Anything after it is proxies. Falling back to the string "unknown" means every request without the header shares one bucket, which is the safe direction: an attacker who strips the header competes with everyone else who stripped it.

req.json() is wrapped because a malformed body is a 400 and not a 500. Unhandled, it throws inside the handler and Next renders a server error, which makes a client problem look like our problem in the logs.

Then the field rules:

const name = (payload.name ?? "").trim();
const email = (payload.email ?? "").trim().toLowerCase();
const subject = (payload.subject ?? "").trim();
const message = (payload.message ?? "").trim();

if (!name || !email || !message) { /* 400 */ }
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { /* 400 */ }
if (message.length > 5000) { /* 400 */ }
Enter fullscreen mode Exit fullscreen mode

Trimming before the emptiness check is what makes a message of three spaces a 400 rather than an email with nothing in it. Lowercasing the address is for the eventual reply and for matching it against a purchase record by hand, since addresses are case-insensitive in practice and Gmail-capitalised ones otherwise look like different people.

The email pattern is deliberately loose. It checks for one @ with something either side and a dot after it, and that is all. A stricter regex rejects valid addresses, and the real validation is that a reply either arrives or bounces. Rejecting a genuine customer at the form is a worse outcome than accepting a typo.

The validation rule the form never mentions

Here is the honest flaw. The client marks three fields required and uses type="email", so the browser catches the empty and malformed cases before any request is made. Nothing in the UI knows about this:

if (message.length > 5000) {
  return NextResponse.json({ error: "Message is too long." }, { status: 400 });
}
Enter fullscreen mode Exit fullscreen mode

Paste in six thousand characters and you get "Message is too long." after submitting, with no indication of the limit, no character counter, and your text still sitting in a textarea. The server is right to have the cap, and the form is wrong not to show it. It is on the list, and it is the kind of thing that stays on the list because the error path works and nobody has hit it.

The server-side checks exist regardless of what the form does, which is the part that is not negotiable: required and type="email" are hints to a browser, and fetch does not consult them.

What success looks like

return NextResponse.json({ sent: true });
Enter fullscreen mode Exit fullscreen mode

The client flips to a confirmation panel on anything that is not an error, and on a failure it shows the server's own error string rather than a generic message, so "Too many requests." reaches the person who is being rate limited. sendEmail returns { success, error } instead of throwing, so the route can log the provider's error and return a 500 that says "Please try again", which is true, because a Resend blip is transient.

That one endpoint is the inbound half of four kinds of email this project sends. The outbound side, including the one that tells you a monitored search has stopped working, is in four kinds of email, one endpoint, and only one caller is allowed to throw. There is also a webhook in the other direction, since replies to the alerts are parsed, which went wrong in an entertaining way in our support inbox is a webhook.

If you want to see the form, it is under the FAQ at notifio.app/help, and the thing it supports is described on notifio.app/alerts. Sending a test message is a fine way to check whether the Reply-To trick works, and I will reply, from a different address than the one that wrote to you.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to