← Blog·Oct 15, 2026guidesdeliverability
Avoid email abuse on your website
Bots don't want to message you. They want your form to message someone else. How to wire a form to eusend so it can't be turned into a spam relay, and what keeps your account clear of our abuse checks.

The contact form is the most abused endpoint on the small web. It is public, it needs no account, and it ends in an email being sent. Bots find them within days of a site going live.
Most of the time the bots are not interested in you. A spammer filling in your form doesn't want to reach your inbox. They want your form to send their text to someone else, from your domain, with your DKIM signature on it. If your form lets them do that, every message they send costs your domain's reputation, and the shared IPs your mail leaves from.
This post covers how to wire a form so that can't happen, and what keeps an account clear of the automated abuse checks we run on every account.
Two kinds of form mail
A form usually sends one or both of these:
- A notification to you. Someone filled in the form, here's what they wrote. It goes to an address on your own domain.
- A confirmation to the visitor. "Thanks, we got your message." It goes to whatever address the visitor typed.
The first one is safe by construction. The worst a bot can do is put spam in your own inbox.
The second one is the problem. The recipient address is typed by a stranger, so anyone can point it at anyone. If the confirmation also repeats what the visitor wrote, even just their name in Hi {name}, you've built a free mail relay. Put the spam in the name field, put the victim's address in the email field, and your form delivers it.
The one rule
Never send text a visitor wrote to an address a visitor typed.
Everything else in this post is defence in depth. This rule alone stops the relay.
await eusend.emails.send({
from: 'Acme <hello@acme.com>',
to: form.email,
subject: `Thanks for reaching out, ${form.name}!`,
html: `<p>We received your message:</p><blockquote>${form.message}</blockquote>`,
})
await eusend.emails.send({
from: 'Acme website <forms@acme.com>',
to: 'support@acme.com',
replyTo: form.email,
subject: 'New contact form message',
text: `From: ${form.name} <${form.email}>\n\n${form.message}`,
})
The second version sends the visitor's message to you, and sets replyTo to the address they typed. When you hit reply, it goes to them. You get the same conversation, and nothing a stranger writes ever goes out to a stranger's address.
We read mail with this shape kindly. When every recipient is on your own domain, we won't hold your account because a bot pushed spam through your form into your own inbox. Spam that lands only with you hurts no one else.
If you really need a confirmation
Sometimes the visitor does need an email: a booking, a quote request, a ticket number. Then:
- Keep the copy fixed. No name, no message, no subject line built from form fields. "We received your request, reference #4821" carries everything it needs to.
- Escape everything that does have to go in, and never put form input into
htmlraw. - Send it once per address, and rate-limit by both address and IP. A real visitor submits once or twice, not forty times.
- Consider double opt-in for anything that keeps mailing the person afterwards. Confirm the address first, send the rest after.
Stop bots before they reach your server
None of these is enough alone. Together they remove almost all of the noise.
- A challenge. Cloudflare Turnstile, hCaptcha or Friendly Captcha (EU-hosted) all work without making visitors click traffic lights. Verify the token on the server. A challenge you only check in the browser doesn't check anything.
- A honeypot. A hidden field real people never fill in. Bots that fill in every input give themselves away.
- A minimum fill time. Put a timestamp in the form when it renders. A submission that arrives in under two seconds wasn't typed by a person.
- A rate limit per IP. A few per hour is generous for a contact form.
- Server-side validation. Check that the email field holds one address, not a comma-separated list, and limit the message length.
Put together, as a Next.js route handler:
import { Eusend } from '@eusend_dev/sdk'
const eusend = new Eusend(process.env.EUSEND_FORMS_KEY)
export async function POST(request: Request) {
const form = await request.formData()
const email = String(form.get('email') ?? '').trim()
const name = String(form.get('name') ?? '').slice(0, 100)
const message = String(form.get('message') ?? '').slice(0, 5000)
if (form.get('website')) return Response.json({ ok: true })
if (Date.now() - Number(form.get('rendered_at')) < 2000) return Response.json({ ok: true })
if (!/^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email)) {
return Response.json({ error: 'Invalid email' }, { status: 400 })
}
const ip = request.headers.get('cf-connecting-ip') ?? ''
const check = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET!,
response: String(form.get('cf-turnstile-response') ?? ''),
remoteip: ip,
}),
}).then((r) => r.json())
if (!check.success) return Response.json({ error: 'Challenge failed' }, { status: 400 })
await eusend.emails.send({
from: 'Acme website <forms@acme.com>',
to: 'support@acme.com',
replyTo: email,
subject: 'New contact form message',
text: `From: ${name} <${email}>\n\n${message}`,
})
return Response.json({ ok: true })
}
The honeypot and timing checks return success on purpose. Telling a bot it failed only teaches it what to change.
Add your rate limiter of choice in front, whether that's Upstash, a Redis counter or your platform's built-in rules.
Give the form its own key
Create a separate API key for the form, scoped to the one domain it sends from, and keep it on the server. Never ship it in a client bundle, a static site's JavaScript or a public repo.
If the key leaks, it can only send from that domain, and you can revoke it without touching the keys the rest of your app uses. One key per site also makes it obvious in the dashboard which site is sending what.
What keeps you clear of our checks
We let accounts sign up and send within minutes, so our protection runs alongside your sending rather than in front of it. Content screening reads a message after it has already gone to the mail server, so it never delays anything you send. When something looks wrong, it stops what comes next.
We won't publish the exact thresholds, because they'd be a map for the people they exist to stop. But here's what legitimate senders run into most, and how to avoid it:
- Send under your own name. The display name should be your product or company: "Acme" or "Acme website", not "Support Team" or "Account Security". A sender named after another company's brand is refused at the API with a
403. - Test with test keys.
eu_test_keys run the full send lifecycle, webhooks included, without delivering anything. A new account firing test sends at a pile of personal Gmail and Outlook addresses looks a lot like someone rehearsing a campaign. - Use real links and real recipients. Template placeholders like
example.comlinks are a common trait of phishing kits, and recipients at reserved domains are rejected withRECIPIENT_DOMAIN_UNDELIVERABLEbefore anything is sent. - Send what your product sends. An account that has sent password resets for months and suddenly sends a crypto promotion gets a closer look. That's the point.
- Watch bounces and complaints. A high bounce or complaint rate over seven days suspends sending (
SENDING_SUSPENDED). An unprotected form is the most common way to get there: bots type made-up addresses, the confirmations bounce, and your rate climbs. Suppressions stop you mailing the same dead address twice. - Stay inside the acceptable use policy. Some categories aren't allowed no matter how clean the setup is.
If we get it wrong
We do, sometimes. Automated checks that stop abusers quickly will occasionally stop someone who isn't one. This week one of them held a paying customer overnight because bots had pushed spam through their contact form into their own support inbox. That's the case described above, and it's the reason self-addressed mail now gets the benefit of the doubt.
When your account is held, the dashboard shows a banner and new sends are refused. Write to support@eusend.dev. A person reads every one, and a hold that turns out to be wrong is released and goes into tuning the check that made it.
The checklist
- Form notifications go to your domain, with the visitor in
replyTo. - Confirmations, if any, use fixed copy and contain nothing the visitor wrote.
- A server-verified challenge, a honeypot, a minimum fill time and a per-IP rate limit.
- A separate, domain-scoped API key that lives only on the server.
eu_test_keys for testing, and a sender name that's yours.
That's an afternoon of work, and it's the difference between a form that collects messages and one that sends spam for strangers.