Un formulario debe responder claramente: ¿ha llegado el mensaje? Utiliza fetch para enviar sin salir de la página, mostrar la carga y confirmar solo cuando el endpoint acepte. Si falla, conserva el texto y explica cómo continuar.
A continuación tienes un ejemplo completo de HTML y JavaScript para el endpoint de mailcontact, sin framework. Incluye etiquetas visibles, validación del navegador, campo trampa, protección contra dobles clics y avisos en la página. Sustituye el endpoint de ejemplo por la URL real de tu proyecto antes de probarlo.
Por qué no basta con resolver fetch
Una petición puede llegar al servidor aunque este rechace el envío. Por ejemplo, un email no válido devuelve HTTP 400 y una cuota mensual agotada devuelve HTTP 429. fetch no rechaza su promesa solo porque la respuesta tenga un estado HTTP de error. Comprueba response.ok y el cuerpo esperado antes de restablecer el formulario. Este comportamiento se documenta en Guía de Fetch de MDN.
mailcontact devuelve {"ok":true} para solicitudes aceptadas. Un campo trampa rellenado recibe la misma respuesta, aunque se descarta: comprueba también los errores de autocompletado. Los visitantes normales deben dejarlo vacío. El referencia del endpoint documenta el formato de respuesta y los límites de validación.
Un formulario de contacto completo con mensajes de éxito y error
Para una integración nueva, utiliza preferentemente el script alojado de MailContact o SDK personalizado. Inserta la comprobación oculta, realiza la verificación automática y conserva los borradores si hay errores. Las integraciones Free con script deben mostrar un enlace visible «Processed by mailcontact.app». El ejemplo con fetch muestra las respuestas HTTP e incluye la comprobación obligatoria; los mensajes sospechosos sin soporte de verificación se retienen en Spam.
<form id="contact-form" action="https://mailcontact.app/api/forms/your-form-id" method="POST">
<fieldset>
<legend>Contact us</legend>
<label for="contact-name">Name (optional)</label>
<input id="contact-name" name="name" autocomplete="name" maxlength="200" />
<label for="contact-email">Your email</label>
<input id="contact-email" type="email" name="email"
autocomplete="email" maxlength="320" required />
<label for="contact-message">Message</label>
<textarea id="contact-message" name="message" maxlength="5000" required></textarea>
<div class="contact-meta" aria-hidden="true">
<label for="contact-website">Website</label>
<input id="contact-website" name="company_website" type="text"
tabindex="-1" autocomplete="off" />
</div>
<button type="submit">Send message</button>
</fieldset>
<p id="contact-status" role="status" aria-live="polite" aria-atomic="true"></p>
</form>
<style>
.contact-meta {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
<!-- Keep the script after the form so its elements exist. -->
<script>
const form = document.querySelector("#contact-form");
const fields = form.querySelector("fieldset");
const button = form.querySelector('button[type="submit"]');
const status = document.querySelector("#contact-status");
let sending = false;
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (sending || !form.reportValidity()) return;
// Collect values before disabling the fieldset.
const body = new FormData(form);
if (!String(body.get("message") || "").trim()) {
status.textContent = "Please write a message before sending.";
form.elements.namedItem("message").focus();
return;
}
sending = true;
fields.disabled = true;
form.setAttribute("aria-busy", "true");
button.textContent = "Sending…";
status.textContent = "Sending your message…";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(form.action, {
method: "POST",
body,
signal: controller.signal,
});
const data = await response.json().catch(() => null);
if (!response.ok || data?.ok !== true) {
let message = "Your message could not be sent. Your text is still here.";
if (response.status === 400) {
message = "Check your email and message length, then try again.";
} else if (response.status === 429) {
message = data?.code === "MONTHLY_QUOTA_EXCEEDED"
? "This form has reached its monthly limit. Please use another contact method on this site."
: "Too many requests. Please wait before trying again.";
} else if (response.status === 404) {
message = "This contact form is unavailable. Please use another contact method on this site.";
}
status.textContent = message;
return;
}
form.reset();
status.textContent = "Thanks! Your message has been received.";
} catch {
status.textContent = "We could not confirm delivery. Your text is still here. Check your connection before trying again.";
} finally {
clearTimeout(timeout);
sending = false;
fields.disabled = false;
form.removeAttribute("aria-busy");
button.textContent = "Send message";
}
});
</script>Conserva los estilos habituales de tu formulario. La pequeña regla CSS solo oculta la trampa y no impone diseño. Sin JavaScript, el formulario sigue enviándose de forma nativa, pero actualmente el endpoint muestra JSON en vez de redirigir a un agradecimiento.
¿Qué ocurre mientras se envía?
El controlador recoge los campos antes de desactivar el fieldset: FormData omite los desactivados. Así tampoco se pierden cambios hechos durante la petición al reiniciar tras el éxito. La protección independiente sending evita los envíos simultáneos.
La zona de estado existe desde el inicio y su texto cambia con el proceso. Informa sin ventanas de alerta ni depender solo del color. El finally restablece el formulario tras un éxito, un error del servidor o un fallo de red para que el botón no quede desactivado permanentemente.
Conserva el mensaje si la entrega es incierta
El tiempo de espera de 15 segundos evita esperas indefinidas, pero no deshace mensajes ya aceptados. Puede fallar la conexión después de guardar y antes de confirmar. Por eso el error indica entrega incierta, no asegura que nada se envió.
El ejemplo no reintenta automáticamente solicitudes inciertas porque podría duplicar mensajes. Conserva el borrador para decidir. Evitar duplicados con garantía exige idempotencia explícita en el receptor; desactivar un botón no basta.
Distingue los límites temporales de las cuotas mensuales
Una respuesta 429 puede indicar un límite temporal o una cuota mensual agotada. Comprueba el JSON code: MONTHLY_QUOTA_EXCEEDED identifica este último caso. Pedir que se reintente en unos segundos no ayuda si se ha agotado la cuota mensual.
Para límites temporales, el endpoint proporciona una Retry-After . Puedes utilizarla para mostrar una cuenta atrás. Si se agota la cuota, ofrece otra forma real de contacto. El formulario y el email de Pro comparten cuota, así que ese email no sirve de alternativa cuando se agota. Comprueba el uso y la límites del plan antes de un lanzamiento importante.
Comprueba el formulario antes de publicarlo
- Envío correcto: aparece la confirmación, se limpia el formulario y el mensaje está en la bandeja.
- Datos no válidos: un email no válido o un mensaje vacío muestra un aviso útil sin borrar los demás campos.
- Navegador sin conexión: se conserva el borrador, se informa de entrega incierta y se reactiva el botón.
- Clics repetidos: solo hay una solicitud en curso mientras se muestra el estado de envío.
- Entrega de notificaciones: un destino de Slack o Discord configurado recibe una alerta tras aceptar un mensaje.
No muestres éxito solo porque termine una animación o se pulse un botón. Vincúlalo a la respuesta del endpoint, permite recuperar los errores y comprueba el recorrido hasta la bandeja. Para la recepción, sigue la Guía de notificaciones de Slack o el Guía de Discord.