Forms
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
Contact and lead forms for landing pages. The visitor submits without logging in, the submission is saved in the database and emailed to you. With bot protection (honeypot field + minimum fill-in time always on, captcha optional), rate limit per IP and protection against open redirect.
Requires the Mejl (email) plugin. Tested against PocketBase 0.40.4.
#Installation
- Install Mejl first (and set up SMTP), then copy
pb_hooks/andpb_migrations/to your PocketBase and restart. - Create a form in
zk_formularin the admin panel:
| Field | |
|---|---|
namn | Used in the address: kontakt → /api/zk/formular/kontakt (a–z, 0–9, -, _) |
rubrik | Shown in the email to you, e.g. "Offertförfrågan" (quote request) |
falt | Which fields the form has (see below) |
mottagare | Where the submissions are emailed |
aktiv | Off = the form responds 404 |
tack_url | Optional. Where regular forms are sent after submission. Must be on the app's address or in tillatna_adresser. |
captcha | ingen, friendly, turnstile or hcaptcha |
kvitto | Send a short "thanks, we have received your message" to the sender (the first field of type epost) |
max_per_timme | Submissions per IP and hour. Empty = 10 |
falt is a JSON list:
[
{ "namn": "namn", "etikett": "Namn", "typ": "text", "obligatorisk": true, "max": 100 },
{ "namn": "epost", "etikett": "E-post", "typ": "epost", "obligatorisk": true },
{ "namn": "telefon", "etikett": "Telefon", "typ": "text" },
{ "namn": "meddelande", "etikett": "Meddelande", "typ": "lang", "obligatorisk": true, "max": 2000 }
]
Types: text (one line), lang (multiple lines), epost (email). max is the number of characters (default 200 / 5000 / 254, at most 20,000). At most 30 fields. Field names must not start with zk_. The schema is checked when you save the form. Fields that are not in the schema are never saved, regardless of what is sent.
- Settings:
zk_formular_installningaralready has one row. It holds the captcha keys (see below),tillatna_adresser(comma-separated addresses for your landing page if it is on a different domain than PocketBase, e.g.https://min-sajt.se, https://www.min-sajt.se),minsta_sekunder(default 3) andhemlighet(created automatically — do not touch it).
- Is PocketBase behind a proxy (Cloudflare, nginx, Caddy)? Set Settings → Trusted proxy so that PocketBase sees the visitor's real IP. Otherwise all visitors share the proxy's IP — and thereby the same limit per hour.
#Usage
Every form needs a fresh start time (zk_start) from the server. It proves how long the form has been open; bots that submit immediately are rejected.
#With JavaScript (fetch)
const PB = "https://din-pb.se"
const f = await fetch(`${PB}/api/zk/formular/kontakt`).then(r => r.json())
// f = { namn, rubrik, falt: [...], captcha: "friendly", sitekey: "…", start: "1762…", honungsfalt: "zk_webbplats" }
const r = await fetch(`${PB}/api/zk/formular/kontakt`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namn: "Anna", epost: "anna@exempel.se", meddelande: "Hej!",
zk_start: f.start, zk_webbplats: "",
"frc-captcha-response": tokenFranWidgeten }),
})
// 200 { ok: true } · 400 { fel, falt: { epost: "ogiltig e-postadress" } } · 404 · 429 · 503
#Regular HTML form
<form id="kontakt" method="post" action="https://din-pb.se/api/zk/formular/kontakt">
<input name="namn" required>
<input name="epost" type="email" required>
<textarea name="meddelande" required></textarea>
<!-- Honeypot field: must be empty. Hide it with CSS, NOT type="hidden" (bots skip those). -->
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label>Leave empty <input name="zk_webbplats" tabindex="-1" autocomplete="off"></label>
</div>
<input type="hidden" name="zk_start">
<input type="hidden" name="zk_tack" value="https://min-sajt.se/tack"> <!-- optional, must be allowlisted -->
<button>Send</button>
</form>
<script>
fetch("https://din-pb.se/api/zk/formular/kontakt").then(r => r.json())
.then(f => { document.querySelector("#kontakt [name=zk_start]").value = f.start })
</script>
After submission the visitor is sent (303) to zk_tack or the form's tack_url. If there is none, a simple thank-you page is shown. Errors are shown as a simple error page.
Both application/x-www-form-urlencoded and multipart/form-data work. Values are saved exactly as they were typed (0701… keeps the zero).
#Captcha
Choose per form. The key is stored in a hidden field in zk_formular_installningar or as an environment variable (the environment wins):
| Provider | Setting | Environment variable | Widget's field |
|---|---|---|---|
| Friendly Captcha (Germany, EU — first choice) | friendly_api_nyckel (hidden), friendly_sitekey, friendly_eu | ZK_FORMULAR_FRIENDLY_NYCKEL | frc-captcha-response |
| Cloudflare Turnstile | turnstile_hemlig (hidden) | ZK_FORMULAR_TURNSTILE_HEMLIG | cf-turnstile-response |
| hCaptcha | hcaptcha_hemlig (hidden), hcaptcha_sitekey | ZK_FORMULAR_HCAPTCHA_HEMLIG | h-captcha-response |
The widget adds its own field to the form. With fetch you can also send the token as zk_captcha.
- Friendly Captcha: the plugin uses the v2 API (
/api/v2/captcha/siteverifywithX-API-Key).friendly_eu= use the EU endpointeu.frcapi.com— requires that your Friendly account has the EU endpoint (a paid plan with them). Otherwiseglobal.frcapi.com. - Fail closed: if the provider does not respond within 10 seconds, responds with an error, or the key is missing, the submission is rejected (503 "try again") — it is never let through unchecked. This means that an outage at the provider stops your form. (Friendly Captcha themselves recommend the opposite so as not to lose leads; we chose security. If you want to change that, change it in
verifieraCaptcha.) - The visitor's IP is not sent to the provider.
#Security
- Honeypot field (
zk_webbplats) and minimum fill-in time are always on. If a bot gets caught in them it gets a normal "thanks" (200) but nothing is saved or emailed — so that it does not learn what gave it away. The start time is signed with the server's secret and bound to the form; a forged or missingzk_startgives 400 (that is usually an integration error, and it should be visible), one older than 24 hours gives "reload the page". - Rate limit per IP, form and hour. All attempts are counted, including the bots'.
- IP addresses are never stored. The counter only stores a hash of secret + form + IP + current hour, and is deleted after one to two hours. Since the hour is included, it is not possible, even with the database in hand, to follow an IP between hours. Note that the IP is still processed (an IP address is personal data) — mention this in your privacy policy.
- Email injection: single-line fields get no line breaks (they become spaces), email fields must be a single plain address (no commas,
<>or line breaks). The recipient always comes from the form definition. The Mejl plugin HTML-escapes all values. - The receipt does not contain what the sender wrote — otherwise anyone could use your form to send any text to any address, with your sender.
- Open redirect:
zk_tackandtack_urlmust be on the app's own address (Settings → Application URL) or intillatna_adresser. A disallowedzk_tackrejects the whole submission (400). - Forms, submissions, settings and counters are only readable by superusers.
GET /api/zk/formular/{namn}only shows fields, captcha type and the public sitekey — never the recipient or keys.
#Limitations
- The email to you has
Reply-Toset to the sender's address (if the form has an email field), so you can reply directly. The address is validated as a single address. - One recipient per form.
- No file uploads.
- Email addresses with å/ä/ö in the address (internationalised addresses) are not accepted; domains written in punycode work.
- The start time is not single-use: a bot that fetches it and waits the three seconds gets past the time check. It is the rate limit and captcha that catch those bots.
- Emails are sent directly, without a queue. If SMTP is down, the submission is still saved (
mejl = felinzk_formular_svar), but the email is not retried. - Turnstile's
hostname/actionand Friendly Captcha'ssitekeyin the response are not checked — we trust the provider'ssuccess.
#Testing
./bank.py formular
21 tests. Captcha is tested against a fake provider (ZK_FORMULAR_FRIENDLY_URL, ZK_FORMULAR_TURNSTILE_URL, ZK_FORMULAR_HCAPTCHA_URL redirect the verify address; ZK_FORMULAR_CAPTCHA_TIMEOUT lowers the timeout) — nothing reaches the internet. Passed, failed, timeout and server error are tested for Friendly Captcha and Turnstile, passed/failed for hCaptcha. The plugin has not been run against the real providers. The security tests are mutant-verified: every protection has been deliberately broken and caught by a test.