Skip to content

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.

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.

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.

The verification pipeline runs “cheapest first” — whatever costs the least runs first, so an attack is rejected as early as possible:

#LayerEffect
1Rate limits (global, IP, site key)in-memory, ahead of everything else
2Tenant active? Origin allowed?kill switch and allowlist, server-side
3Challenge tokenHMAC-signed, 10-minute TTL, bound to site and origin
4Minimum fill timeform filled in under n seconds = bot
5Honeypotinvisible field; a hit fakes success
6IP/ASN reputationTor exits, iptoasn.com, custom blocklist
6bOrigin-country ruleper-tenant country/region policy (IPv4 and IPv6)
7Proof-of-workbit threshold configurable per tenant
8Nonceredeemed atomically — a token is valid exactly once (replay protection)
9Input hardeningfield and length limits, CRLF protection, Reply-To validation
10Email block patternsblocking patterns matched against the reply address
11Daily quotapersistent, counts both pending and sent mail
12Spam scorequarantine 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).

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.

  • SMTP routes with STARTTLS (587), implicit TLS (465), or unencrypted (only for relays on the same machine).
  • Fixed sender per route — no freely chosen From from the form. This protects the sender domain’s reputation; the submitter’s reply address travels as Reply-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.

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-To is the tenant’s recipient address.

Per tenant you can restrict which countries may submit a form:

ModeMeaning
offno restriction (default)
allowonly the listed countries may submit
denythe 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.

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.

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, a read scope, and their own rate limit.
  • Statistics per tenant and submission history with status filtering.

Details: Security.

  • 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).

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.

  • 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.