Guías16 min de lectura
Envía los envíos de formularios HTML a tu email
Recibe cada envío de formulario en tu bandeja sin servidor de correo: por qué fallan mailto: y mail() de PHP, y una configuración que filtra spam y te deja responder.
Un formulario HTML no puede enviar emails por sí solo, porque el navegador no tiene forma de hablar con un servidor de correo. Apunta el action del formulario a un backend de formularios, que recibe el envío, filtra el spam, lo guarda y te manda un email a ti y a tu equipo. Llama email al campo del email y, al pulsar Reply en tu bandeja, responderás directamente a quien rellenó el formulario.
La mayoría de los sitios necesitan una sola cosa de un formulario de contacto: que el mensaje llegue a una bandeja que alguien lea. Resulta más difícil de lo que parece. HTML tiene un elemento <form>, pero no una forma de enviar correo, y los viejos atajos le devuelven el trabajo al visitante o acaban en spam. Esta guía explica por qué, compara las opciones con honestidad y monta un formulario que te envía un email en cualquier sitio y con cualquier stack.
Por qué un formulario no puede enviar emails por sí solo
Cuando un visitante pulsa Send, el navegador hace una petición HTTP a la URL del action del formulario. Es todo lo que puede hacer un formulario. Enviar un email requiere una conversación SMTP con un servidor de correo, desde una máquina en la que confíe el servidor receptor. Un navegador no puede abrir esa conexión y, aunque pudiera, el correo desde el portátil de un visitante cualquiera se rechazaría. Así que siempre hay algo en un servidor entre el formulario y tu bandeja: tu propio código, un script en tu hosting o un servicio.
Con un backend de formularios, ese algo es una URL. El formulario envía a https://ikiform.com/f/<id> e Ikiform hace el resto:
- El visitante envía tu formulario, y el navegador o tu servidor lo manda a tu endpoint de Ikiform.
- Ikiform comprueba los campos con la definición del formulario, puntúa el envío como posible spam y lo guarda.
- Un envío que alcanza la puntuación de spam se guarda como spam. Recibe la misma respuesta de éxito, pero no envía ningún email ni webhook.
- Cualquier otro envío manda un email a cada destinatario, con la dirección del visitante como reply-to.
- El email llega a tu bandeja segundos después. Al pulsar Reply respondes al visitante.
Seis formas de llevar un formulario a tu bandeja
Cada una le sirve a alguien. La tabla trata de lo que te cuesta cada una cuando visitantes reales, y bots reales, empiezan a usar el formulario.
| Opción | Bueno para | Contras |
|---|---|---|
action="mailto:you@example.com" | Sin servidor, sin cuenta, un solo atributo. | No se envía nada. El navegador abre la app de correo del visitante con un borrador, o no hace nada si no tiene ninguna configurada. Tu dirección queda en la página al alcance de los scrapers. Consulta la guía de Gmail. |
Un script PHP con mail() | Funciona en casi cualquier hosting PHP compartido, sin terceros. | Depende de la configuración de correo del hosting, y el correo que dice venir de tu dominio suele fallar SPF o DMARC y acaba en spam. Sin filtro antispam, sin almacenamiento y sin PHP en absoluto en los hostings estáticos. Consulta sin PHP. |
| Un SDK de email en el navegador, como EmailJS | Sin servidor. Plantillas de email que diseñas en su panel. | Las claves y los ids de plantilla están en el JavaScript de tu página, así que cualquiera puede enviar tus plantillas con su propio texto. Consulta la alternativa a EmailJS. |
| Tu propia ruta más una API de email, como Resend, Postmark, Amazon SES o Nodemailer por SMTP | Control total: tu asunto, tu HTML, una confirmación al visitante. | Tienes que construir la validación, el filtro antispam, los límites de frecuencia, el almacenamiento y los reintentos, verificar un dominio de envío y necesitas un servidor incluso para un sitio estático. Consulta la guía de Next.js. |
| Google Apps Script como app web | Funciona con una cuenta de Google que ya tienes. | Las cuotas de scripts limitan a cuántos destinatarios puede enviar al día, la URL no tiene protección antispam y el script lo mantienes tú. |
| Un backend de formularios, como Ikiform, Formspree o Web3Forms | Una URL en action. Filtro antispam, almacenamiento y email sin servidor. | El diseño y el asunto del email los pone el servicio. Con Ikiform, nunca se envía nada por email al visitante. |
Si necesitas un email totalmente con tu marca, o una confirmación al visitante, escribe tu propia ruta con una API de email. Si quieres el mensaje en tu bandeja y poder responder, sin spam, un backend de formularios es lo que menos código requiere. El resto de esta guía usa Ikiform.
El formulario
Tres campos cubren la mayoría de los formularios de contacto: quién es, a dónde responder y qué quiere. Elige tu stack abajo; la elección se mantiene en todos los ejemplos de código de estas guías.
Este formulario funciona sin nada de JavaScript: el navegador lo envía, Ikiform te manda un email y el visitante llega a la página de _redirect. Cada campo tiene debajo un hueco vacío para errores, que usa el script de abajo.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<p data-error="name"></p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p data-error="email"></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
<p data-error="message"></p>
<input type="hidden" name="page" value="/contact" />
<input type="hidden" name="_redirect" value="https://example.com/contact/sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<p id="contact-status" role="status"></p>
</form>El script mantiene al visitante en la página. Envía con fetch, escribe cada error del servidor debajo de su campo y cambia el formulario por una línea que repite la dirección a la que responderás, para que una errata sea fácil de detectar.
const form = document.getElementById("contact");
const status = document.getElementById("contact-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
for (const slot of form.querySelectorAll("[data-error]")) slot.textContent = "";
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
const email = form.elements.namedItem("email").value;
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = `Sent. We'll reply to ${email}.`;
form.replaceWith(done);
return;
}
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
const slot = form.querySelector(`[data-error="${field}"]`);
if (slot) slot.textContent = messages[0];
}
status.textContent = result.error.fields ? "Check the fields above." : result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});Una server action envía el mensaje con tu clave secreta. Convierte el error.fields de Ikiform en un mensaje por campo y devuelve los valores escritos, para que no se pierda nada de lo que escribió el visitante si hay un error.
"use server";
import { headers } from "next/headers";
export interface ContactState {
sent: boolean;
email?: string;
errors: Record<string, string>;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
message: String(formData.get("message") ?? "").trim(),
};
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, email: values.email, errors: {} };
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(result?.error?.fields ?? {})) {
errors[field] = (messages as string[])[0] ?? "Check this field.";
}
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your message. Try again.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { sent: false, errors: {} };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.sent) return <p role="status">Sent. We'll reply to {state.email}.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={state.values?.name} />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} defaultValue={state.values?.message} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.
¿Quieres profundizar con Next.js? Lee Un formulario de contacto en Next.js que envía emails.
Zod comprueba cada campo en la action antes de enviar nada, e isInputError te da sus mensajes por campo. La página debe renderizarse bajo demanda, así que mantén prerender = false y añade un adaptador.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "Write at least a sentence."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, page: "/contact" }),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { email: input.email };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Sent. We'll reply to {result.data.email}.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{fields.message && <p role="alert">{fields.message[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.
La función de servidor lee el FormData en .validator(), lo envía desde el servidor y devuelve la dirección de respuesta o un error por campo.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Outcome = { sent: true; email: string } | { sent: false; errors: Record<string, string> };
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<Outcome> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...data, page: "/contact" }),
});
const result = await response.json();
if (result.ok) return { sent: true, email: data.email };
const errors: Record<string, string> = { form: String(result.error.message) };
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
errors[field] = String((messages as string[])[0]);
}
return { sent: false, errors };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
const errors = outcome && !outcome.sent ? outcome.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ sent: false, errors: { form: "Couldn't send your message. Try again." } });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">Sent. We'll reply to {outcome.email}.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.name && !errors.email && !errors.message ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.
El action de la ruta reenvía el mensaje y devuelve los errores de campo de Ikiform a través de actionData, conservando el código de estado de Ikiform con data().
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: String(formData.get("name") ?? "").trim(),
email,
message: String(formData.get("message") ?? "").trim(),
page: "/contact",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true as const, email };
const fields: Record<string, string[]> = result.error.fields ?? {};
return data(
{ sent: false as const, message: String(result.error.message), fields },
{ status: response.status },
);
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) return <p role="status">Sent. We'll reply to {actionData.email}.</p>;
const fields = actionData?.fields ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.
La action por defecto devuelve fail() con los errores de campo. update({ reset: false }) conserva lo que escribió el visitante cuando el servidor responde con un error. Declara IKIFORM_SECRET_KEY en src/env.ts con defineEnvVars para que $app/env/private pueda leerla.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(data.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { values, fields, message: String(result.error.message) });
}
return { sentTo: values.email };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const fields = $derived<Record<string, string[]>>(form?.fields ?? {});
</script>
{#if form?.sentTo}
<p role="status">Sent. We'll reply to {form.sentTo}.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{#if fields.name}<p role="alert">{fields.name[0]}</p>{/if}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{#if fields.email}<p role="alert">{fields.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{#if fields.message}<p role="alert">{fields.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message && !Object.keys(fields).length}<p role="alert">{form.message}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
{/if}Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.
Una ruta de servidor guarda la clave secreta y pasa el mensaje de Ikiform como message de createError y los errores de campo en su data; $fetch devuelve ambos en el cliente como data.message y data.data.fields. Nuxt rellena runtimeConfig.ikiformSecretKey a partir de NUXT_IKIFORM_SECRET_KEY.
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; email?: string; message?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
page: "/contact",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sentTo: body.email };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const values = reactive({ name: "", email: "", message: "", _gotcha: "" });
const fields = ref<Record<string, string[]>>({});
const error = ref("");
const sentTo = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
fields.value = {};
error.value = "";
try {
const result = await $fetch("/api/contact", { method: "POST", body: values });
sentTo.value = result.sentTo ?? values.email;
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
fields.value = data?.data?.fields ?? {};
error.value = data?.message ?? "Couldn't send your message. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="values.name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" v-model="values.email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="values.message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input v-model="values._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>Las versiones del lado del servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo del servidor. Cada ejemplo de servidor reenvía la cabecera Origin del navegador cuando la hay, porque el fetch del propio servidor no envía ninguna e Ikiform cuenta con ella los envíos de prueba gratuitos desde localhost.
Sin servidor propio, el navegador envía directamente a Ikiform. En este código no va ninguna clave; Allowed domains decide qué sitios pueden enviar.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [fields, setFields] = useState<Record<string, string[]>>({});
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setFields({});
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
setFields(result.error.fields ?? {});
setError(result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Sent. We'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}El mismo envío desde el navegador como componente de Vue. El formulario conserva su action, así que sigue enviándose aunque el script no cargue.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sentTo = ref("");
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
sending.value = true;
fields.value = {};
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
sentTo.value = String(body.get("email"));
return;
}
fields.value = result.error.fields ?? {};
error.value = result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Sustituye
frm_xxxxxxxxxxxxxxxxpor el id de tu formulario, que está en el panel. name="email"convierte ese campo en la dirección reply-to del email de notificación. Conserva el nombre aunque la etiqueta diga "Tu email".pagees un campo oculto normal. Aparece en el email, así sabes desde qué página llegó el mensaje._gotchaes el honeypot. Las personas nunca lo ven; un bot que lo rellena se guarda como spam y no se envía ningún email._redirectsolo funciona con una URLhttps:en uno de los Allowed domains del formulario. Omítelo y configura Redirect after submit en los ajustes del formulario, o Ikiform muestra su propia página de agradecimiento.
Las guías de frameworks van más allá: Next.js, Astro, SvelteKit, Nuxt, React, Vue y HTML simple. Los creadores de sitios como Webflow y Carrd tienen sus propias páginas, y la plantilla de formulario de contacto añade un campo de tema.
Define también los campos en el panel, e Ikiform rechaza un envío que no coincida con 422 validation_failed y un mensaje por campo, que el código de arriba muestra junto a cada input. Pruébalo aquí; esto ejecuta el mismo validador en tu navegador:
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
Cómo es el email
Cada envío aceptado manda un email sencillo y legible. Esta es la versión de texto de uno:
From: Ikiform <notifications@ikiform.com>
To: you@yourcompany.com
Reply-To: ada@example.com
Subject: New submission: Contact
Contact got a new submission on Oct 25, 2026, 9:14 AM UTC.
name: Ada Lovelace
email: ada@example.com
message: Do you ship to Canada? I'd like to order 40 units before December.
page: /contact
View it in Ikiform:
https://ikiform.com/dashboard/submissions?form=frm_xxxxxxxxxxxxxxxx&submission=sub_...- Llega desde
notifications@ikiform.com. No configuras registros DNS ni un dominio de envío, porque el correo sale del dominio de Ikiform. - El asunto es siempre
New submission: <form name>._subjectno cambia el asunto de los emails de notificación. Se pasa a los webhooks y a la API comometa.subject. Para distinguir los formularios, dale a cada uno un nombre claro en el panel, como "Contacto, página de precios". - Los campos aparecen en el orden en que se enviaron, etiquetados por su
name. Los campos que empiezan con_se omiten. - Un formulario o una respuesta muy largos se acortan, y el email indica cuántos campos más hay en el panel. La página de límites tiene las cifras.
- Los campos que parecen contraseñas o números de tarjeta se omiten, y el email de un envío que los contiene se retiene para revisión. No recojas credenciales con un backend de formularios.
- Los miembros del espacio de trabajo reciben un botón View submission. Las personas de fuera del espacio de trabajo reciben los campos y un enlace para darse de baja.
Quién recibe el email
Los destinatarios se configuran por formulario en el panel, nunca desde el propio formulario. Un campo que indicara el destinatario permitiría a cualquiera usar tu formulario para enviar emails a quien quisiera, así que Ikiform no tiene ninguno.
| Quién | Cómo se añaden | Notas |
|---|---|---|
| Por defecto | No hay que hacer nada | El miembro elegido como Notification email del espacio de trabajo, o todos los propietarios del espacio de trabajo si no hay ninguno elegido |
| Miembros elegidos | Márcalos en los ajustes de Notifications del formulario | Solo miembros con una dirección de email verificada |
| Personas de fuera del espacio de trabajo | Add someone outside the workspace y luego Send link | Reciben emails solo después de confirmar. Hasta 5 por formulario, y cada una puede darse de baja |
Cada formulario envía emails a 10 direcciones como máximo. Cada destinatario recibe su propia copia en lugar de un CC compartido, así nadie ve las direcciones de los demás. Si el buzón de un destinatario rebota, Ikiform pausa los emails a esa dirección durante un tiempo y el panel muestra Emails bounced a su lado. Varios destinatarios explica cómo repartir por tema, algo que los destinatarios por sí solos no pueden hacer.
Responde a la persona, no a Ikiform
Ikiform toma la dirección reply-to primero de un campo _replyto y, si no, de un campo llamado email, email_address, your_email, reply_to, contact_email. El valor debe ser una única dirección de email válida o se ignora. Con un reply-to configurado, al pulsar Reply en Gmail, Outlook o Apple Mail se abre una respuesta al visitante, y tu respuesta sale desde tu propia dirección.
Sin él, una respuesta va a notifications@ikiform.com, que nadie lee. Es la sorpresa más habitual, y casi siempre se debe a un campo llamado user_email o Email Address con un espacio. Renómbralo o añade _replyto. La guía de reply-to cubre formularios con dos campos de email y creadores de sitios que renombran los campos por ti.
Cuando llega mucho a la vez
Cada formulario envía hasta 50 emails de notificación por hora, o 10 mientras el espacio de trabajo tenga menos de 7 días. A partir de ahí, el resto de los envíos de esa hora se guardan como siempre y llegan en un único email de resumen cuando termina la hora. El límite protege tu bandeja, y la opinión que tu proveedor de correo tiene del correo de Ikiform, cuando un formulario se publica en algún sitio grande o un bot se cuela entre los filtros.
- Los primeros 50 envíos a un formulario dentro de una hora de reloj envían cada uno un email como siempre.
- Los envíos posteriores de esa misma hora se guardan y aparecen en el panel al instante, sin un email por cada uno.
- Cuando termina la hora, cada destinatario recibe un resumen que los cuenta y enlaza al panel.
El asunto del resumen es el número de envíos y el nombre del formulario; no enumera los campos ni configura un reply-to, así que responde a esos mensajes desde el panel. Si superas el límite con frecuencia, el email no es el canal adecuado para ese formulario: envíalo a Slack o a una bandeja compartida mediante un webhook.
Mantén el spam fuera de tu bandeja
El email encarece el spam: cada envío de un bot es una notificación que alguien abre. Ikiform guarda el spam con una respuesta de éxito normal, así el bot no aprende nada, y no envía ningún email por él. Cuatro cosas deciden qué cuenta como spam:
| Defensa | Qué detecta | Configuración |
|---|---|---|
| Honeypot | Bots que rellenan todos los inputs | El input oculto _gotcha |
| Puntuación de spam | Enlaces en el campo del nombre, tres o más enlaces, frases de spam conocidas, direcciones desechables y repeticiones idénticas. Con 5, el envío es spam | Ninguna |
| Dominios permitidos | Envíos desde otros sitios | Añade tu dominio en los ajustes del formulario |
| Verificación humana | Bots que usan un navegador real | Pon Spam check en Ikiform |
Revisa de vez en cuando el filtro Spam del panel. Si un mensaje real acaba ahí, márcalo como no spam y su email se envía en ese momento. La documentación de spam explica cada señal, incluido cuándo activar la verificación humana.
Lo que el email no puede hacer
Ikiform no envía emails a la persona que rellena el formulario. Los emails de notificación van solo a ti y a tu equipo, así que no hay respuesta automática, email de confirmación ni doble opt-in. Envíalos desde tu herramienta de email, activada por un webhook. Si tu sitio promete "hemos recibido tu mensaje", muéstralo en la página tras el envío, donde ya lo hace el código de arriba.
- Una bandeja compartida del equipo. Añade la dirección del equipo como destinataria, o reenvía desde tu proveedor de correo.
- Un canal de chat. Envía cada envío a Slack, Discord o Microsoft Teams por webhook. El chat va bien para formularios que reciben más de unos pocos mensajes al día.
- Una hoja de cálculo o un CRM. Google Sheets, o cualquier herramienta a través de Zapier o n8n.
- Archivos adjuntos. La subida de archivos aún no está disponible. Un envío con un archivo recibe
422 files_not_supported. Pide en su lugar un enlace a un archivo compartido.
Desactivar el email
Algunos formularios funcionan mejor sin email, como un registro a una newsletter con mucho tráfico. Desactiva Email new submissions en los ajustes de Notifications del formulario. Los envíos se siguen guardando y los webhooks se siguen disparando.
Elige tu caso
La configuración de arriba es la misma en todas partes. Estas páginas cubren una bandeja concreta, un stack concreto o la herramienta que estás dejando.
- Sin PHPSustituye un script contact.php que llama a mail() por un formulario que envía a un endpoint alojado, conservando tus nombres de campo, tu página de agradecimiento y las respuestas.
- A GmailRecibe los envíos de formularios en Gmail sin enlaces mailto:, contraseñas de aplicación ni Apps Script; luego etiquétalos, mantenlos fuera del spam y responde desde Gmail.
- Varios destinatariosEnvía un formulario por email a todo tu equipo, o dirige cada mensaje a la persona adecuada según el tema, sin dejar que el propio formulario elija quién recibe el correo.
- Responde desde tu bandejaHaz que Reply en una notificación de formulario responda a quien lo rellenó: qué nombres de campo funcionan, cuándo usar _replyto y por qué se pierden las respuestas.
- Alternativa a EmailJSSaca un formulario de contacto de EmailJS: por qué las claves de email en el navegador son un riesgo, qué cambia en tu código y qué pierdes, como la respuesta automática.
- Next.jsDos formas de enviar por email un formulario de Next.js con una Server Action: enviarlo tú con Resend o Nodemailer, o reenviarlo a un backend de formularios, comparadas con honestidad.
Comprueba que funciona
Repasa esto antes de publicar el formulario. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149, $ con créditos que no caducan. Consulta los precios.
- En Allowed domains del formulario, añade el dominio de tu sitio, más
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - En los ajustes de Notifications del formulario, comprueba que Email new submissions está activado y que están marcadas las personas correctas.
- Rellena el formulario en tu página y envíalo. Llegas a tu página de agradecimiento, o a la alojada de Ikiform.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que mandaste.
- Abre el email de notificación. El asunto es
New submission:y el nombre del formulario, y aparecen todos los campos que enviaste. - Pulsa Reply. La línea To muestra la dirección que escribiste en el formulario, no la de Ikiform.
- Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no manda ningún email ni webhook. - ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.
Solución de problemas
| Problema | Qué hacer |
|---|---|
| El envío está en el panel, pero no llegó ningún email | Mira primero en tu carpeta de spam o de promociones, y añade notifications@ikiform.com a tus contactos. Después revisa los ajustes de Notifications del formulario: puede que el email esté desactivado, que una dirección externa siga en Waiting for confirmation o que muestre Emails bounced. |
| El envío está en el panel, pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |
| Reply va a notifications@ikiform.com | No se encontró ningún reply-to. Llama al campo del email email, email_address, your_email, reply_to o contact_email, o añade un campo _replyto con la dirección del visitante. |
| Los emails se detuvieron y llegó un resumen en su lugar | El formulario superó los 50 emails en una hora (10 en un espacio de trabajo nuevo). Todo está en el panel. Busca una ráfaga de bots en Spam, o usa un webhook para los formularios con mucho tráfico. |
_subject no cambia el asunto | _subject no cambia el asunto de los emails de notificación. Se pasa a los webhooks y a la API como meta.subject. |
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en Allowed domains del formulario. Añádelo, más localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
422 validation_failed | El envío no coincide con la definición de campos del formulario. error.fields indica cada campo y el motivo. Haz que el name del input coincida con el id del campo. |
402 test_limit_reached | El espacio de trabajo no tiene plan y ya usó sus 50 pruebas gratuitas desde localhost este mes. Elige un plan o usa Send test en el panel. |
Preguntas
Por sí solo, no. Un formulario solo hace una petición HTTP, y enviar correo requiere un servidor que hable con un servidor de correo. Un backend de formularios es ese servidor: apunta el action del formulario a él y te enviará los emails, así tu sitio puede seguir siendo estático.
Un action mailto: no envía nada. Le pide al ordenador del visitante que abra una app de correo con un borrador, lo que falla para quien no tenga una configurada, y además el visitante tiene que pulsar Enviar. Mucha gente abandona ahí, y tú nunca sabrás que lo intentó.
No. El asunto es siempre "New submission:" seguido del nombre del formulario, así que ponle a cada formulario un nombre claro. Un campo _subject se guarda y se pasa a los webhooks y a la API, pero no cambia el email.
Hasta 10 por formulario: los miembros del espacio de trabajo que marques en los ajustes del formulario, más hasta 5 direcciones externas que confirmen mediante un enlace. Cada una recibe su propia copia.
No. Ikiform te envía emails a ti y a tu equipo, no a quien envía el formulario, así que no hay respuesta automática ni email de confirmación. Envía un webhook a tu herramienta de email y manda la confirmación desde ahí.
Se envían desde el propio dominio de Ikiform, así que no necesitas registros SPF ni DKIM para tu dominio. El primero aún puede acabar en spam o en promociones; añade notifications@ikiform.com a tus contactos o crea un filtro para que no pase nunca.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 10.000 envíos por 49 $ o 50.000 envíos por 149, $ con créditos que no caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.