SAVEFORM
All posts
Engineering

Why your form's auto-reply shouldn't come from your form

A short engineering story about how letting users control auto-reply email content turns your service into a free SMTP relay, and the boring fix.

4 min reademail·security·design

When we built auto-reply for SaveForm, the obvious API was the one everyone asks for: hidden form fields. Add _autoReply=true and _autoReplyMessage=“Hi, we got it” to the form payload, and the service sends an acknowledgement email back to the submitter. Five minutes to specify, ten minutes to implement. We almost shipped it. Then we noticed it was a free email relay.

Anything that lets the form payload control both the recipient and the email body is, by definition, an open SMTP relay.

The attack, in three lines

Imagine the API we almost built. Someone finds your public contact form. They submit:

JSONmalicious payload
{
  "email": "victim@example.com",
  "_autoReply": "true",
  "_autoReplySubject": "Your invoice is overdue",
  "_autoReplyMessage": "Pay this link or we'll send to collections..."
}

Our service dutifully sends a phishing email to the victim, signed by our verified sender domain. From the victim's perspective, the email passed SPF, DKIM, and DMARC, because it really did come from us. We just told it to.

The damage spreads

The bug isn't that we sent an email. The bug is that we sent an email whose subject and body were authored by a stranger to a recipient also chosen by that stranger.

The fix is boring. That's the point.

The whole problem disappears the moment you constrain who controls what:

  1. Subject and body live on the form record. The form owner authors them once, in the dashboard. The HTTP payload can't override them.
  2. Personalisation via variables, not raw text. The owner's template can include {{name}} and it'll substitute the submitter's name. The submitter never gets to inject prose, only fill in the blanks the owner predefined.
  3. Variable values are HTML-escaped. A mischievous “name” like <script> ends up rendered as text, not executed.
  4. Recipient hardcoded to the form's email field. The submitter can only get the auto-reply at the email they're already submitting from. They can't target someone else.
  5. Spam-flagged submissions skip auto-reply. Disposable email domains, gibberish names, the Gmail dot trick are all already caught by the existing spam scoring. The auto-reply path piggybacks on that.

What the API actually looks like

From the form owner's side:

They open Form settings → Auto-reply and write something like:

texttemplate
Subject: Thanks for reaching out, {{name}}

Hi {{name}},

We got your message and someone from the team will reply within
one business day. In the meantime, if it helps, our docs cover
the most common questions: https://example.com/docs.

Cheers,
The team

From the submitter's side: they fill out the form with name, email, and message like always. Nothing in the form's markup tells them an auto-reply will fire. It just shows up in their inbox a few seconds later.

No Reply-To. Yes, on purpose.

The other tempting feature is to set the Reply-To header on the auto-reply to the form owner's email, so the submitter can reply directly to a human. We thought about it and chose not to.

The takeaway

When designing a feature that sends email on someone's behalf, map every input's control plane to who's allowed to set it:

Anything more permissive turns your service into a vector. Anything less permissive shuts down legitimate use cases. This is the line we settled on, and so far we haven't had to walk it back.

For the boring details, the auto-reply docs cover the limits, the variable syntax, and the exact conditions under which the email fires.

Auto-reply that won’t torch your domain

Server-controlled templates, payload-controlled variables, spam-aware delivery. The boring design that doesn't make the news.

Why your form's auto-reply shouldn't come from your form — SaveForm.io | SaveForm.io