Skip to content
PN Scripts

Development

A contact form endpoint for a static site

  • PN Scripts
  • 11 min read

A static website, plain HTML or built with a generator such as Hugo, Eleventy or Astro, is fast, cheap and hard to break. Then someone asks for a contact form, and a static site has nothing to send it to. The usual answers are a third-party form service, which sends every message through someone else's servers and often adds a monthly fee, or a serverless function, which means another platform to learn. If the site lives on ordinary shared hosting, there is a simpler option: the hosting already runs PHP. This guide builds a small endpoint, one PHP file, and shows what it has to get right: validation, spam, e-mail headers, delivery and messages that must not get lost. Everything here was run with PHP 8.4 and tested in Chrome.

#What the endpoint has to do

  • Accept the form only from your own site, and only by POST.
  • Stop most spam without making people solve a puzzle.
  • Check every field and explain problems in words people understand.
  • Build an e-mail that cannot be turned into a spam relay.
  • Send it so that it arrives in your inbox, not in spam.
  • Never lose a message, even when the mail server is down.
  • Work with JavaScript (errors next to the fields) and without it (a plain page).

#The endpoint

<?php
// contact.php: the endpoint for a static site's contact form.
const SECRET = 'replace-with-a-long-random-string';   // php -r 'echo bin2hex(random_bytes(32));'
const ALLOWED_ORIGINS = ['https://www.example.com'];
const TO = 'office@example.com';
const FROM = 'website@example.com';   // an address on your own domain, so SPF and DMARC pass
const THANK_YOU = '/thank-you.html';

function token(int $time): string
{
    return $time.'.'.hash_hmac('sha256', (string) $time, SECRET);
}

function token_age(string $token, int $now): ?int
{
    [$time, $mac] = array_pad(explode('.', $token, 2), 2, '');

    return ctype_digit($time) && hash_equals(hash_hmac('sha256', $time, SECRET), $mac) ? $now - (int) $time : null;
}

function one_line(string $value): string
{
    return trim(preg_replace('/[\r\n\t]+/', ' ', $value));   // nothing can start a new header
}

function reply(int $status, array $errors = []): never
{
    if (str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) {
        http_response_code($status);
        header('Content-Type: application/json');
        exit(json_encode(['ok' => $errors === [], 'errors' => $errors], JSON_UNESCAPED_UNICODE));
    }
    if ($errors === []) {
        header('Location: '.THANK_YOU, true, 303);
        exit;
    }
    http_response_code($status);
    exit('<p>'.implode('</p><p>', array_map('htmlspecialchars', $errors)).'</p><p><a href="https://pnscripts.com/contact.html">Back to the form</a></p>');
}

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    exit(json_encode(['token' => token(time())]));
}

$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
if ($origin !== null && ! in_array($origin, ALLOWED_ORIGINS, true)) {
    reply(403, ['form' => 'This form cannot be sent from another site.']);
}
if (($_POST['website'] ?? '') !== '') {
    reply(200);   // the honeypot: only a bot fills in the hidden field; pretend it worked
}
$token = (string) ($_POST['token'] ?? '');
if ($token !== '') {   // set by the page's script; a visitor without JavaScript has none
    $age = token_age($token, time());
    if ($age === null || $age < 3 || $age > 86400) {
        reply(422, ['form' => 'The form was sent too quickly, or the page is too old. Please reload it and try again.']);
    }
}

$name = one_line((string) ($_POST['name'] ?? ''));
$email = one_line((string) ($_POST['email'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
$errors = [];
if ($name === '' || mb_strlen($name) > 100) {
    $errors['name'] = 'Please enter your name.';
}
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors['email'] = 'Please enter a valid e-mail address.';
}
if (mb_strlen($message) < 10 || mb_strlen($message) > 5000) {
    $errors['message'] = 'The message should be 10 to 5000 characters long.';
} elseif (preg_match_all('~https?://~i', $message) > 2) {
    $errors['message'] = 'Please include no more than two links.';
}
if ($errors !== []) {
    reply(422, $errors);
}

$subject = '=?UTF-8?B?'.base64_encode('Contact form: '.$name).'?=';
$headers = ['From: '.FROM, 'Reply-To: '.$email, 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8'];
$body = "Name: $name\nE-mail: $email\n\n$message\n";
if (! mail(TO, $subject, $body, implode("\r\n", $headers))) {
    // Keep the message outside the web root and send it later, instead of losing it.
    $file = __DIR__.'/../outbox/'.date('Ymd-His').'-'.bin2hex(random_bytes(4)).'.json';
    file_put_contents($file, json_encode(compact('name', 'email', 'message'), JSON_UNESCAPED_UNICODE));
}
reply(200);

Put it next to your HTML pages and the outbox folder one level up, outside the web root, so nobody can download waiting messages. Replace the secret with a long random string; it never leaves the server.

#Spam: three checks before any validation

Origin. Browsers send an Origin header with a POST from a page. If it names another site, the form was copied or embedded somewhere else, and the endpoint refuses it with 403. Bots that post directly often send no Origin at all, which is why this check is only the first of three.

Honeypot. The form has a "Website" field inside a hidden paragraph. People never see it; simple bots fill in every field they find. When it is filled, the endpoint answers "sent" and sends nothing, so the bot has no reason to try again.

Time trap. When the page loads, its script asks the endpoint for a token: the current time and an HMAC signature of it. The form sends the token back. A token that is forged, older than a day or younger than three seconds is refused, because no person fills in a contact form in under three seconds. The signature is checked with hash_equals(), which takes the same time whether the first or the last character is wrong. A visitor without JavaScript has no token; the form still works for them, and the honeypot and the other checks remain.

Add a limit on links (most spam carries several) and, on a busy site, a limit of a few messages per visitor per hour. Count those by a salted hash of the IP address rather than the address itself, so the counter is not a log of your visitors. A CAPTCHA can come last, if spam still gets through.

#Headers that cannot be hijacked

The classic attack on contact forms is header injection. If a field ends up in an e-mail header and contains a line break, the rest of the line becomes a new header. A name like Ana, a line break and Bcc: thousands of addresses turns your form into a spam sender with your domain's reputation behind it.

The endpoint closes this in three ways. one_line() replaces line breaks in the name and the e-mail with spaces. The e-mail must pass FILTER_VALIDATE_EMAIL, which refuses any line break. And the subject, the only header that contains the visitor's name, is encoded in base64, so whatever the name holds stays inside it. We sent exactly that attack: the message arrived with "Ana Bcc: victim@example.net" as a harmless piece of text in the subject and body, and no extra header.

#Mail that arrives

The From address must be on your own domain. It is tempting to put the visitor's address there so that "Reply" goes to them, but your server is not allowed to send mail for gmail.com or abv.bg: SPF and DMARC checks fail and the message lands in spam or is rejected. Send from an address such as website@yourdomain, and put the visitor's address in Reply-To. Replying then works as expected. Make sure your domain has SPF and DMARC records; our guide to business e-mail on your own domain explains them.

PHP's mail() keeps the example short, but it hands the message to the hosting's local mail system, and its return value only says the message was accepted for delivery, not that it arrived. Where you can, send through the SMTP server of a real mailbox on your domain, with a login, over TLS. The message then leaves through the same server as your normal mail and is signed the same way.

#Never lose a message

A contact form that says "sent" while the message disappears is worse than no form. When mail() fails, the endpoint writes the message to the outbox as a small JSON file, and the visitor is still told it was received. We tested it with a mail command that always fails: the visitor saw success and the message was waiting in the outbox. A daily cron job can try to send waiting messages again and alert you if any are old. Delete them once sent; they hold personal data.

#The form, with and without JavaScript

<form action="/contact.php" method="post" id="contact">
  <input type="hidden" name="token">
  <p hidden><label>Website <input name="website" tabindex="-1" autocomplete="off"></label></p>
  <p><label>Name <input name="name" required maxlength="100"></label></p>
  <p><label>E-mail <input name="email" type="email" required></label></p>
  <p><label>Message <textarea name="message" required minlength="10"></textarea></label></p>
  <button>Send</button>
</form>
<script>
  const form = document.getElementById("contact");
  fetch("/contact.php").then((r) => r.json()).then((d) => { form.elements.token.value = d.token; });
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    form.querySelectorAll(".error").forEach((p) => p.remove());
    const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
    const result = await response.json();
    if (result.ok) {
      location.href = "https://pnscripts.com/thank-you.html";
      return;
    }
    for (const [field, text] of Object.entries(result.errors)) {
      const p = Object.assign(document.createElement("p"), { className: "error", textContent: text });
      (form.elements.namedItem(field) ?? form.querySelector("button")).after(p);
    }
    form.querySelector(".error")?.previousElementSibling?.focus();
  });
</script>

Without JavaScript the form posts normally: on success the endpoint answers with a 303 redirect to the thank-you page, so refreshing it does not send the message twice; on errors it shows a simple page with the problems and a link back. With JavaScript the script sends the form in the background with Accept: application/json, puts each error right after its field and moves the focus to the first one. In headless Chrome, a short message and an incomplete address showed two errors next to their fields with the focus on the e-mail field; a form sent right after loading showed the time trap message under the button; and a message in Bulgarian arrived intact and the page moved on to the thank-you page.

#Privacy

A contact form collects personal data: a name, an address and whatever people write. Mention it in your privacy notice: what is collected, why, where it goes and how long you keep it. Keep logs free of message contents, and keep the outbox short-lived. This is a practical note, not legal advice; for the wider picture see our guide to GDPR and cookies.

#A script that does this

We built this out into Pismonosha, a contact form endpoint for static sites on shared hosting: one folder next to your pages, plain PHP with no database and no Composer. It handles several forms with their own recipients and field rules, attachments whose content must match their type, errors in Bulgarian and English next to each field, the honeypot, a signed time trap, a per-visitor limit that never stores IP addresses, an optional CAPTCHA, SMTP with STARTTLS or SSL, an outbox with retry from cron, and a command-line check that logs in to your mail server before you go live. It was tested with 24 PHPUnit tests, an end-to-end run in Chrome against a local mail server and a STARTTLS and SSL check, and is listed as Coming soon.

#Frequently asked questions

Do I need a database for a contact form?

No. The message goes by e-mail. A small folder outside the web root for messages that could not be sent yet is enough.

Why do my contact form e-mails land in spam?

Most often because the From address is the visitor's, or a domain your server may not send for. Send from an address on your own domain, put the visitor in Reply-To, and set up SPF and DMARC.

Is a honeypot enough against spam?

Against simple bots, mostly yes. Combine it with a time trap, a link limit and a per-visitor limit; keep a CAPTCHA for sites that still get spam after that.

Does the form work without JavaScript?

It should. Post the form normally, redirect to a thank-you page on success and show a simple page with the problems on errors; let the script improve that, not replace it.

#Sources

Keep reading

Keep reading

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile, API and game development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here