A contact form needs to answer one question clearly: did the message arrive? Use fetch to submit the fields without leaving the page, show a sending state while the request runs, and display success only after the endpoint confirms acceptance. If the request fails, leave the visitor's text in place and explain what they can do next.
Below is a complete HTML and JavaScript example for the mailcontact form endpoint. It needs no frontend framework. It includes visible labels, browser validation, a honeypot, a duplicate-click guard and an inline status message. Replace the example endpoint with your project's real URL before testing.
Why a resolved fetch is not enough
A request can reach a server successfully while the server refuses the submission. For example, an invalid email returns HTTP 400 and an exhausted monthly allowance returns HTTP 429. fetch does not reject its promise just because the response has an HTTP error status. Check response.ok and the expected response body before resetting the form. This behaviour is documented in MDN's Fetch guide.
mailcontact returns {"ok":true} for accepted requests. A filled honeypot deliberately gets the same response while being discarded, so test your trap for autofill mistakes too. Normal visitor submissions should leave that field empty. The endpoint reference documents the response format and validation limits.
A complete contact form with success and error messages
For a new integration, prefer the hosted MailContact script or custom SDK. It injects the hidden check, completes automatic verification and preserves drafts on errors. Free script integrations require a visible “Processed by mailcontact.app” link. The raw fetch example below demonstrates HTTP feedback; it includes the required check, but suspicious messages without verification support are held in Spam for review.
<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>Keep your site's normal form styles around this markup. The small CSS rule is only for the honeypot; it does not prescribe your layout. Without JavaScript, the form still posts natively, but the current mailcontact endpoint displays JSON instead of redirecting to a thank-you page.
What happens while the message is sending?
The handler collects the fields before disabling the fieldset. Disabled controls are not included when constructing FormData, so reversing those steps would lose the payload. Disabling the fields also prevents someone from changing their draft during the request and then having those new edits erased by a successful reset. The separate sending guard prevents overlapping submissions.
The status region starts in the page and is updated with text as the state changes. It provides feedback without an alert box or relying only on a colour change. The finally block restores the form after success, a server error or a network failure, so visitors are not left with a permanently disabled button.
Preserve the message when delivery is uncertain
The 15-second timeout stops the browser waiting indefinitely. It cannot undo a message the server has already accepted. A connection can also fail after the message was saved but before the browser received confirmation. That is why the network error says delivery could not be confirmed, rather than claiming nothing was sent.
The example does not automatically retry uncertain requests: a second attempt could create a duplicate message. It preserves the draft so the visitor can decide what to do. If your workflow needs guaranteed duplicate prevention across retries, that requires an explicit idempotency mechanism on the receiving service; a disabled button alone cannot provide it.
Handle short-term limits and monthly quotas differently
A 429 response can mean a temporary rate limit or a full monthly allowance. Check the JSON code: MONTHLY_QUOTA_EXCEEDED identifies the latter. Asking the visitor to try again in a few seconds is unhelpful when the form has reached its monthly quota.
For temporary rate limits, the endpoint supplies a Retry-After header. You can use it to add a countdown if your interface needs one. For a full quota, provide a real alternative contact method on your website. A Pro project's form and receiving email share the account allowance, so that project email is not a fallback for an exhausted quota. Check your usage and the plan limits before a busy launch.
Check the form before publishing
- Successful submission: the confirmation appears, the form clears, and the message is present in the project inbox.
- Invalid input: an invalid email or a blank message produces useful feedback while keeping the other fields intact.
- Offline browser: the draft remains, delivery is described as uncertain, and the button becomes usable again.
- Repeated clicks: only one request is in flight while the sending state is active.
- Notification delivery: a configured Slack or Discord destination receives an alert after an accepted message.
Do not show success just because an animation finished or a button was clicked. Tie it to the endpoint response, keep failures recoverable, and verify the whole path through to your inbox. For the receiving side, follow the Slack notification walkthrough or the Discord guide.