How to Make a Website Contact Form Send Emails Reliably
Practice4 minInnovialabs editorial team
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:
- The browser posts the data to your server-side handler (an API route or serverless function).
- The handler validates the data and filters out spam.
- The lead is stored: a database, CRM, spreadsheet or Telegram message.
- Only then is an email sent via SMTP or a transactional email service.
- 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
| Option | Pros | Cons |
|---|---|---|
| SMTP of your mailbox (Google Workspace, Microsoft 365, etc.) | simple, you already have the mailbox | sending limits, an app password in server config |
| Transactional service (SendGrid, Mailgun, Postmark, Amazon SES, Resend) | good deliverability, logs, bounce webhooks | needs DNS setup, paid plans as volume grows |
| Serverless handler (Vercel, Netlify, Cloudflare) | no server of your own | still needs SMTP or an email API inside |
| Hosted form services | zero code | data 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".