Astro Dostępność Prywatność

Formularz kontaktowy na statycznej stronie: jedna funkcja, wysyłka z serwera i jeden błąd 500

Jedna funkcja Vercel obok statycznego Astro: formularz od treści, trzy zabezpieczenia przed botami, EmailJS z serwera, działanie bez JS i jeden błąd ESM.

Autor: Mateusz Muszarski Zaktualizowano 6 min czytania In English

Ta strona jest statyczna, więc formularz kontaktowy wysyła dane do jednej funkcji Vercel, api/contact.ts. Funkcja odsiewa boty, waliduje wiadomość tym samym modułem co strona i wysyła ją przez REST API EmailJS, więc żaden klucz nie trafia do przeglądarki, a zabezpieczeń nie da się ominąć. Formularz działa też bez JavaScriptu, a 6 października 2026 jeden import sprawił, że nie doszła żadna wiadomość.

TL;DR

  • Jedna funkcja obok statycznego builda: bez adaptera, bez bazy danych, z jednym modułem walidacji wspólnym dla strony i serwera.
  • Najpierw treść: jedno pole tekstowe, adres do odpowiedzi znaleziony w samej wiadomości i trzy zabezpieczenia przed botami (honeypot, Turnstile, limit żądań).
  • Import bez .js przeszedł wszystkie testy, a na produkcji wywracał każde żądanie błędem HTTP 500. Teraz pilnuje go test jednostkowy.

Dlaczego jedna funkcja, a nie serwer?

Strona buduje się z output: "static": każda podstrona to zwykły HTML z CDN Vercela. Typowa odpowiedź Astro na jedną dynamiczną trasę to adapter i prerender = false, o czym pisałem w Astro czy Next.js na portfolio. Zrobiłem mniejszy krok: zwykłą funkcję Vercel w apps/web/api/. Środowisko Node.js na Vercelu zamienia plik z folderu /api w funkcję i pozwala wyeksportować każdą metodę HTTP osobno:

export async function POST(request: Request): Promise<Response> {
  const form = await request.formData();
  // ochrona przed botami, walidacja, Turnstile, potem EmailJS
}

export function GET(request: Request): Response {
  return Response.redirect(new URL("/en/#contact", request.url).href, 303);
}

Bez adaptera astro preview dalej serwuje zbudowaną stronę bez zmian, a region funkcji (fra1) to jedno ustawienie w vercel.json. Resztę stosu opisałem w studium przypadku tej strony.

Co dzieje się z wiadomością?

  1. 01 Formularz Zwykły POST na /api/contact/ albo to samo żądanie wysłane w miejscu przez skrypt strony.
  2. 02 Funkcja api/contact.ts we Frankfurcie, bez żadnej bazy danych.
  3. 03 Sprawdzenia Honeypot, limit żądań, wspólna walidacja, Turnstile. Odpowiada pierwsze, które się nie uda.
  4. 04 EmailJS Jego REST API, wołane kluczami, które zna tylko serwer.
  5. 05 Skrzynka Reply-To niesie e-mail nadawcy, jeśli go podał, więc odpowiedź to jedno kliknięcie.

Każde sprawdzenie ma własną odpowiedź, więc strona może powiedzieć dokładnie, co poszło nie tak:

Sprawdzenie Kiedy się nie udaje Odpowiedź
Honeypot ukryte pole jest wypełnione 400, spam
Limit żądań ponad 5 wiadomości w 10 minut z jednego IP (w jednej instancji) 429, rate
Walidacja wiadomość nie ma od 2 do 4000 znaków albo brakuje adresu, na który da się odpowiedzieć 400, konkretny problem
Turnstile token nie przechodzi siteverify (tylko gdy ustawiony jest sekret) 400, turnstile
EmailJS poczta nie jest skonfigurowana albo wywołanie się nie udaje 503 albo 502

Zdarzenia formularza w statystykach strony niosą temat i rodzaj błędu, nigdy treść (jak te statystyki obywają się bez cookies).

Co znaczy „najpierw treść”?

Formularz kontaktowy: trzy przyciski tematu, jedno puste pole tekstowe i przycisk Wyślij Pusty
Ten sam formularz z wiadomością zakończoną adresem e-mail, który wypełnił też pole odpowiedzi, z notką, że został znaleziony Adres znaleziony
Formularz na stronie głównej przed pisaniem i w trakcie. Pole odpowiedzi pojawia się, gdy tylko zaczniesz pisać, i wypełnia się z treści wiadomości.

Formularz pomija zwykłe pola na imię, e-mail i temat. Pole odpowiedzi przyjmuje e-mail, numer telefonu albo link do profilu na LinkedIn i wypełnia się samo, jeśli wiadomość już któryś z nich zawiera. Notka pod nim ma aria-live="polite", więc czytnik ekranu odczyta ją bez przerywania. Gdy sam zmienisz zawartość pola, skrypt przestaje ją nadpisywać.

export function findReply(message: string): { kind: ReplyKind; value: string } | null {
  const email = message.match(EMAIL)?.[0];
  if (email) return { kind: "email", value: email };
  const linkedin = message.match(LINKEDIN)?.[0];
  if (linkedin) return { kind: "linkedin", value: linkedin };
  for (const match of message.matchAll(new RegExp(PHONE.source, "g"))) {
    const value = match[0].trim();
    if (digits(value) >= 7 && digits(value) <= 15) return { kind: "phone", value };
  }
  return null;
}

Pierwszeństwo ma e-mail, potem LinkedIn, potem numer telefonu od 7 do 15 cyfr. Reguły mieszkają w src/lib/contact.ts, wspólnym dla skryptu strony i funkcji, więc przeglądarka i serwer nie mogą się różnić w ocenie poprawnej wiadomości. Przy wyłączonym JavaScripcie serwer uruchamia tę samą funkcję findReply. Gdy w polu odpowiedzi jest numer telefonu, inputmode zmienia się na tel, co według MDN pozwala przeglądarce pokazać klawiaturę telefoniczną.

Jak zatrzymać boty, nie męcząc ludzi?

Trzy warstwy, od najtańszej:

  • Honeypot. Pole website leży poza ekranem w kontenerze z aria-hidden, z tabindex="-1" i autocomplete="off". Ludzie, klawiatura i czytniki ekranu do niego nie trafiają; trafia bot, który wypełnia każde pole.
  • Turnstile. Skrypt Cloudflare ładuje się dopiero, gdy zaczniesz pisać, i tylko wtedy, gdy ustawiony jest klucz witryny. Z appearance: "interaction-only" widżet pokazuje się tylko wtedy, gdy potrzebna jest Twoja interakcja. Cloudflare ostrzega, że sam widżet po stronie klienta nie chroni formularza, więc funkcja sprawdza każdy token przez siteverify. Token da się zweryfikować tylko raz, dlatego po nieudanej wysyłce strona resetuje widżet.
  • Limit żądań. Funkcja liczy wiadomości z jednego IP w pamięci. Licznik żyje w jednej instancji, więc to tylko dodatkowa warstwa; twardy limit należy do reguły w Vercel WAF, która zatrzymuje żądania, zanim dotrą do funkcji.

Czwarta warstwa nie przetrwała. Minimalny czas wypełniania blokował prawdziwych ludzi, którzy piszą szybko, więc 7 października go usunąłem. Test jednostkowy mówi to teraz wprost: „rejects a filled honeypot, never a fast sender”.

Dlaczego to serwer wywołuje EmailJS, a nie przeglądarka?

EmailJS jest pomyślany do wywoływania z przeglądarki, a jego FAQ mówi, że ktoś, kto skopiuje klucz publiczny, wyśle tylko Twoje szablony z Twoją treścią. Ukrywanie tego klucza nie jest więc powodem. Powody są dwa:

  1. Zabezpieczenia liczą się tylko wtedy, gdy nie da się ich ominąć. Gdyby strona sama wołała EmailJS, każdy skrypt mógłby zawołać go tak samo, z pominięciem honeypota i Turnstile.
  2. Klucz prywatny zostaje prywatny. Funkcja wysyła klucz prywatny konta jako accessToken, a ten klucz istnieje tylko w zmiennych środowiskowych hostingu.

EmailJS domyślnie to blokuje: żądania API z aplikacji spoza przeglądarki są wyłączone, dopóki nie włączysz ich w ustawieniach Security konta, gdzie włącza się też klucz prywatny.

await fetch("https://api.emailjs.com/api/v1.0/email/send", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    service_id: process.env.EMAILJS_SERVICE_ID,
    template_id: process.env.EMAILJS_TEMPLATE_ID,
    user_id: process.env.EMAILJS_PUBLIC_KEY,
    accessToken: process.env.EMAILJS_PRIVATE_KEY,
    template_params: { subject, text, reply_to, topic, locale },
  }),
});

Szablon EmailJS trzyma odbiorcę i układ maila, a funkcja tylko uzupełnia zmienne. Bez ID usługi, ID szablonu albo klucza publicznego odpowiada 503. Po tym, jak 7 października wysyłki na produkcji kończyły się błędem 502, funkcja zapisuje w logu status i powód odmowy EmailJS, nigdy treść ani nadawcę.

Czy działa bez JavaScriptu?

Tak. Formularz to zwykłe <form action="/api/contact/" method="post">, a jego ścieżka kończy się ukośnikiem jak każdy adres na tej stronie, więc POST nigdy nie jest przekierowywany. Ukryte pole przekazuje język strony.

Funkcja czyta nagłówek Accept. Skrypt strony prosi o JSON i dostaje { "ok": true } albo { "ok": false, "error": "reply-missing" }. Zwykłe wysłanie formularza dostaje 303 See Other na stronę wyniku w języku nadawcy, na przykład /pl/contact/sent/. MDN opisuje 303 jako typową odpowiedź na POST, po której klient pobiera potwierdzenie metodą GET, więc odświeżenie nie wysyła wiadomości drugi raz. Obie strony wyniku mają noindex i nie trafiają do mapy strony.

Jak pokazywane są błędy?

Formularz po nieudanej wysyłce: ciemna ramka pod polem odpowiedzi mówi, że formularz nie może teraz wysłać wiadomości, a tekst nadal jest na miejscu
Zrzut z lokalnego builda, w którym pod /api/contact/ nie ma funkcji: 404 to nie jej odpowiedź, więc formularz wraca do „nie może teraz wysłać” i zostawia wszystko, co wpisano.

Słowami, w osobnej ramce, bez kasowania Twojego tekstu. Kryterium WCAG 3.3.1 Error Identification (poziom A) wymaga, żeby błąd był opisany użytkownikowi tekstem, więc każdy rodzaj porażki ma własne zdanie:

Rodzaj Co mówi strona
message-empty Najpierw napisz wiadomość. Wystarczy jedno zdanie.
reply-invalid Na to nie mogę odpowiedzieć. Podaj e-mail, numer telefonu albo link do profilu LinkedIn.
rate Za dużo wiadomości z tego połączenia. Odczekaj kilka minut albo skopiuj mój e-mail.
network Brak połączenia. Twój tekst nadal tu jest; spróbuj ponownie albo skopiuj mój e-mail.

Ramka to jeden akapit z role="alert", ukryty, dopóki coś nie pójdzie źle. Pasuje to do rady MDN, by tej roli używać dla treści pojawiającej się dynamicznie. Pole, w którym jest problem, dostaje aria-invalid i fokus. Te zdania to teksty Lingui, przetłumaczone w czasie builda i przekazane skryptowi jako JSON w atrybucie data- (jak ta strona działa w trzech językach).

Co się zepsuło 6 października 2026?

Każda wiadomość wysłana przez formularz na produkcji kończyła się błędem HTTP 500. Log funkcji podawał nazwę błędu: ERR_MODULE_NOT_FOUND.

Przyczyną była jedna linia. Plik api/contact.ts importował wspólne reguły jako ../src/lib/contact, bez rozszerzenia. package.json aplikacji ustawia "type": "module", więc Node.js ładuje funkcję jako moduł ES. Ten loader jest surowy: przy imporcie ścieżki względnej albo bezwzględnej rozszerzenie pliku jest obowiązkowe. Funkcja się skompilowała i wdrożyła, a potem przy każdym żądaniu nie dawała się załadować.

Lokalnie nic tego nie wyłapało. Vitest rozwiązuje importy tak jak Vite, a resolve.extensions w Vite próbuje .ts dla importów bez rozszerzenia, więc testy jednostkowe przechodziły. Odtworzyłem błąd, kompilując oba pliki do modułów ES i importując je w Node: bez rozszerzenia ten sam błąd, z rozszerzeniem czysty import.

Poprawka to ../src/lib/contact.js, a test czyta teraz źródło funkcji:

it("imports relative files with their .js extension", () => {
  const source = readFileSync(new URL("./contact.ts", import.meta.url), "utf8");
  const relative = [...source.matchAll(/from "(\.{1,2}\/[^"]+)"/g)].map((m) => m[1]);
  expect(relative.length).toBeGreaterThan(0);
  for (const path of relative) expect(path).toMatch(/\.js$/);
});

Ten sam incydent odsłonił drugi błąd. Gdy platforma odpowiadała własnym błędem, skrypt strony czytał treść odpowiedzi Vercela jak jeden z moich rodzajów błędów, nie znajdował dla niego zdania i pokazywał pusty komunikat. Teraz każda odpowiedź, która nie pochodzi od mojej funkcji, kończy się komunikatem „nie może teraz wysłać”, jak na zrzucie wyżej.

Ograniczenia

  • Limit żądań w funkcji działa w jednej instancji i zaczyna od zera w każdej nowej. Twardy limit wymaga reguły WAF na hostingu, poza tym repozytorium.
  • Turnstile chroni formularz tylko wtedy, gdy ustawione są oba jego klucze: klucz witryny ładuje widżet, a sekret sprawia, że funkcja go sprawdza. Bez nich jedynymi zabezpieczeniami są honeypot i limit żądań.
  • Jeśli w wiadomości pada inny długi numer, na przykład numer zamówienia, może on trafić do pola odpowiedzi. Pole da się poprawić, a notka mówi, co zostało znalezione.
  • Nic nie czeka w kolejce. Gdy EmailJS odmówi, formularz to mówi i podsuwa mój adres e-mail; wiadomość nie zostanie wysłana później.

Jeśli chcesz to sprawdzić, w formularzu kontaktowym wystarczy jedno zdanie.

Źródła

  1. Using the Node.js Runtime with Vercel Functions (Vercel Docs) (otwiera się w nowej karcie) (dostęp: )
  2. WAF Rate Limiting (Vercel Docs) (otwiera się w nowej karcie) (dostęp: )
  3. Modules: ECMAScript modules, Mandatory file extensions (Node.js docs) (otwiera się w nowej karcie) (dostęp: )
  4. Shared options: resolve.extensions (Vite docs) (otwiera się w nowej karcie) (dostęp: )
  5. Turnstile: Server-side validation (Cloudflare Docs) (otwiera się w nowej karcie) (dostęp: )
  6. Turnstile: Client-side rendering (Cloudflare Docs) (otwiera się w nowej karcie) (dostęp: )
  7. Turnstile: Widget configurations (Cloudflare Docs) (otwiera się w nowej karcie) (dostęp: )
  8. EmailJS: /send API (otwiera się w nowej karcie) (dostęp: )
  9. EmailJS: Is it okay to expose my Public Key? (otwiera się w nowej karcie) (dostęp: )
  10. EmailJS: SDK options (private key) (otwiera się w nowej karcie) (dostęp: )
  11. EmailJS Node.js SDK, README (GitHub) (otwiera się w nowej karcie) (dostęp: )
  12. 303 See Other (MDN) (otwiera się w nowej karcie) (dostęp: )
  13. ARIA: alert role (MDN) (otwiera się w nowej karcie) (dostęp: )
  14. inputmode (MDN) (otwiera się w nowej karcie) (dostęp: )
  15. Understanding SC 3.3.1: Error Identification (W3C WAI, WCAG 2.2) (otwiera się w nowej karcie) (dostęp: )

Wszystkie wpisy