Features
An overview of what the gateway actually does. For day-to-day use, see the Admin UI; for embedding on the customer side, see Embedding a form.
Multi-tenancy
Section titled “Multi-tenancy”A tenant is a customer site. It’s created in the Admin UI, and everything else hangs off that:
- Site key (
site_…) — the public identifier used in the client JS. Not a secret, just an association; security lives entirely server-side. - Recipient address — the destination for form emails. It never comes from the form itself, always from the tenant configuration.
- Origin allowlist — which domains submissions are accepted from. Checked server-side, not just via CORS.
- Own SMTP route or shared default route — each tenant can send through its own mail server or through the platform’s.
- Kill switch — one click, and the tenant immediately rejects everything.
Each tenant’s form data lives in its own ORM++ tenant — submissions and nonces are hard-separated as a result.
No form schema
Section titled “No form schema”The gateway has no notion of a field list. Every <input name="…"> is
transmitted and ends up in the mail body; a new field only needs HTML,
no configuration in the gateway. Only three field names are reserved:
_gw_reply (becomes Reply-To), _gw_form (form identifier), and
_gw_hp (honeypot, set by the snippet itself).
Details and limits: Embedding a form.
Protection against spam and abuse
Section titled “Protection against spam and abuse”The verification pipeline runs “cheapest first” — whatever costs the least runs first, so an attack is rejected as early as possible:
| # | Layer | Effect |
|---|---|---|
| 1 | Rate limits (global, IP, site key) | in-memory, ahead of everything else |
| 2 | Tenant active? Origin allowed? | kill switch and allowlist, server-side |
| 3 | Challenge token | HMAC-signed, 10-minute TTL, bound to site and origin |
| 4 | Minimum fill time | form filled in under n seconds = bot |
| 5 | Honeypot | invisible field; a hit fakes success |
| 6 | IP/ASN reputation | Tor exits, iptoasn.com, custom blocklist |
| 6b | Origin-country rule | per-tenant country/region policy (IPv4 and IPv6) |
| 7 | Proof-of-work | bit threshold configurable per tenant |
| 8 | Nonce | redeemed atomically — a token is valid exactly once (replay protection) |
| 9 | Input hardening | field and length limits, CRLF protection, Reply-To validation |
| 10 | Email block patterns | blocking patterns matched against the reply address |
| 11 | Daily quota | persistent, counts both pending and sent mail |
| 12 | Spam score | quarantine instead of delivery above threshold |
Repeatedly suspicious IPs can additionally be reported to a CrowdSec LAPI — the upstream firewall then blocks them before the next request even arrives (Security).
Externally, the gateway stays tight-lipped: every rejection is
400 {"ok":false,"reason":"rejected"}. Which layer triggered is visible
only in the log and the admin panel — a spammer gets no oracle to
calibrate their attempts against. Honeypot hits and quarantine even look
like success to the client.
All numeric values (PoW bits, spam threshold, daily quota, minimum fill
time) are configurable per tenant and can each be disabled (0).
Quarantine instead of discarding
Section titled “Quarantine instead of discarding”Anything above the spam threshold isn’t discarded — it’s quarantined. In the tenant detail view, any submission can be released (which then goes on to delivery) or discarded. False positives don’t cost a message this way.
Delivery
Section titled “Delivery”- SMTP routes with STARTTLS (587), implicit TLS (465), or unencrypted (only for relays on the same machine).
- Fixed sender per route — no freely chosen
Fromfrom the form. This protects the sender domain’s reputation; the submitter’s reply address travels asReply-To. - Retry with backoff via a database-backed queue; a briefly unreachable mail server doesn’t lose a message.
- Test-send button in the admin panel checks connection and login before real forms run against it.
- Health display per route: last error, last successful delivery.
Confirmation email to the submitter
Section titled “Confirmation email to the submitter”Optional per tenant: whoever submits the form receives a short
confirmation at the address they entered themselves (field
_gw_reply). The admin maintains the text as free text — it appears in
the mail exactly as entered.
- Off by default. Without the option enabled and without a reply address in the form, nothing happens.
- Without the submitted data. The confirmation deliberately contains only the fixed text. If the form fields travelled along, the form could be used to send arbitrary text to an arbitrary third-party address — the gateway would become an amplifier.
- Only after the checks pass. Quarantined submissions trigger no confirmation; only releasing them in the admin panel does. A suspected spammer therefore also learns nothing about whether their address is deliverable.
- Counts towards the daily quota, because it goes to an address chosen by the sender — which also caps the abuse potential.
- Replies reach the site owner: the confirmation’s
Reply-Tois the tenant’s recipient address.
Origin-country rule (geo)
Section titled “Origin-country rule (geo)”Per tenant you can restrict which countries may submit a form:
| Mode | Meaning |
|---|---|
off | no restriction (default) |
allow | only the listed countries may submit |
deny | the listed countries may not submit |
Entries are country codes (DE, AT, US …) or groups: DACH
(DE/AT/CH), EU (the 27 member states), EEA (EU + IS/LI/NO),
EUROPE (geographic Europe incl. CH, GB, UA).
The IP → country mapping comes from the very iptoasn dump that is already fetched daily for the ASN check — no extra service, no third-party lookup per submission. IPv4 and IPv6 are treated alike, so the rule cannot be bypassed with an IPv6 connection.
Important — unknown origin does not block. If an IP cannot be
mapped to a country (not routed, dump not loaded yet, or download
failed), it counts as allowed — even in allow mode. Otherwise a
failed download on someone else’s server would take the contact form
down entirely. The remaining layers (PoW, honeypot, spam score,
quarantine) still apply.
Every geo hit shows up as a blocked submission in the admin UI (with
its reason, e.g. “herkunftsland US gesperrt (modus allow)”) and counts
towards attack detection. If the same IP keeps hammering, it crosses
the CrowdSec threshold and gets banned by the upstream firewall —
deliberately with a low weight, so a single business traveller abroad
does not end up in the firewall right away.
Blocklist
Section titled “Blocklist”A custom block list for IP, CIDR, ASN, email addresses, and countries/regions, each with a note. Entries take effect immediately — they’re mirrored into the reputation store and enforced in layer 6 of the pipeline. A country entry here applies globally to all tenants; the per-tenant rule lives in the tenant form.
Admin area
Section titled “Admin area”Web UI and API in the same binary:
- Users with argon2id passwords and roles (RBAC, static catalog in code).
- API keys (
fgw_…) for processes without a browser — with expiration, revocation, areadscope, and their own rate limit. - Statistics per tenant and submission history with status filtering.
Details: Security.
Privacy
Section titled “Privacy”- Self-hosted — form data never leaves your own infrastructure.
- Client IPs are stored in the database only as a truncated SHA-256 hash.
- No cookies, no tracking in the client snippet, no third-party resources.
- GDPR export and purge per customer site via the ORM++ tenant registry — disclosure and deletion are a single operation, not a SQL adventure.
- Audit log via event sourcing: every submission carries its status
history (
received → scored → sent/quarantined → released/discarded).
Client snippet
Section titled “Client snippet”Dependency-free vanilla JS, ~1.6 KB gzipped, minified and served by
the gateway. It fetches the challenge, solves the proof-of-work in a web
worker (falling back to the main thread), submits, and shows the status
inline. Multiple forms per page are wired up automatically; without
JavaScript, the <form> remains a plain form.
Operations
Section titled “Operations”- Single binary — the Go backend, Svelte admin UI, and client
snippet are all compiled in. One installation consists of the binary,
config.json, and a database file. - SQLite or PostgreSQL, switched via a single configuration line, no code changes.
- CGO-free ⇒ cross-compiling for Linux (amd64/arm64), Windows, and macOS without extra toolchains.
- Docker image (Alpine, non-root, read-only, health check) and systemd operation are documented.
- Graceful shutdown on SIGINT/SIGTERM, including background workers.
- Version and build info via
-version,GET /api/v1/system/info, and the web app’s footer.