Skip to content
Innovialabs

How to Make a Website Contact Form Send Emails Reliably

Practice4 min

In short

A reliable form first saves the lead (database, CRM or Telegram) and only then sends an email through a transactional service from a domain with SPF, DKIM and DMARC; a honeypot and rate limits stop most spam.

The short answer

A contact form should never rely on email alone. A setup that works:

  1. The browser posts the data to your server-side handler (an API route or serverless function).
  2. The handler validates the data and filters out spam.
  3. The lead is stored: a database, CRM, spreadsheet or Telegram message.
  4. Only then is an email sent via SMTP or a transactional email service.
  5. The visitor sees "Thank you" once the lead is stored, even if the email is delayed.

The hosting mail() function and sending "as" a Gmail address without setup are common reasons emails land in spam or disappear.

Sending options

OptionProsCons
SMTP of your mailbox (Google Workspace, Microsoft 365, etc.)simple, you already have the mailboxsending limits, an app password in server config
Transactional service (SendGrid, Mailgun, Postmark, Amazon SES, Resend)good deliverability, logs, bounce webhooksneeds DNS setup, paid plans as volume grows
Serverless handler (Vercel, Netlify, Cloudflare)no server of your ownstill needs SMTP or an email API inside
Hosted form serviceszero codedata sits with a third party, less control

For most sites the sensible choice is a transactional service via its API, called from a server-side handler. The API key lives in server environment variables and never reaches the browser.

Handler example

A Node.js route with Nodemailer. The logic is the same in any framework.

import nodemailer from "nodemailer";

const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});

export async function handleLead(body, ip) {
  if (body.website) return { ok: true };          // honeypot: a bot filled the hidden field
  if (!body.name || !body.contact) return { ok: false, error: "invalid" };
  if (await tooManyRequests(ip)) return { ok: false, error: "rate_limit" };

  const lead = await saveLead(body);               // store first

  try {
    await transport.sendMail({
      from: "Website <no-reply@example.com>",
      to: "sales@example.com",
      replyTo: body.email,
      subject: `New lead: ${body.name}`,
      text: `${body.name}\n${body.contact}\n\n${body.message ?? ""}`
    });
  } catch (err) {
    await markForRetry(lead.id, err);              // email failed, lead is safe
  }
  return { ok: true };
}

Note that From is an address on your domain, and the visitor's email goes into Reply-To. Put the visitor's address in From and mail providers will almost certainly reject the message as spoofed.

Spam protection

  • Honeypot. A hidden field (for example website) that humans never see. Bots fill it in — silently ignore those requests.
  • Rate limits. Cap the number of submissions per IP per time window.
  • Timing check. A form submitted one second after the page loaded is most likely a bot.
  • CAPTCHA (Cloudflare Turnstile, reCAPTCHA) — when the honeypot and limits are no longer enough.
  • Server-side validation. Browser checks are easy to bypass, so repeat them on the server.

Deliverability basics

  • Send from your own domain, not a free mailbox.
  • Set up SPF (who may send for the domain), DKIM (message signing) and DMARC (what to do with messages that fail checks). Transactional services give you ready-made DNS records.
  • Keep messages plain: a clear subject, a text version, no shortened links.
  • Watch bounce webhooks if your service offers them.

How not to lose leads

  • Store the lead before sending the email. Email is a notification, not storage.
  • Use a second channel: an email plus a Telegram chat message or a CRM record.
  • Retries: queue failed sends and try again.
  • Monitoring: send a test lead weekly or alert on handler errors.
  • Be honest with the visitor: if storing failed, show an error and an alternative contact, not "Thank you".

FAQ

Most often because the From field uses someone else's or a free address and the domain has no SPF, DKIM and DMARC. Set up the DNS records and send through a service that supports them.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.