Skip to content

About

The lead-ops platform for Astro forms: abandonment capture, recovery emails, quote payments, and a self-hosted admin. No external services, just SQLite and SMTP. MIT.

Topics

Resources

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Repository files navigation

cool-astro-forms

Catch the leads that leave your Astro forms without hitting submit.

When a visitor types into your form and walks away, the lead is saved. Around that capture sits the complete lead-ops platform: recovery emails, quote payments, and a self-hosted admin, with zero external services by default, just a SQLite file and your existing SMTP env vars. 1,373 unit tests and a Playwright e2e suite cover it.

License: MIT npm

Self-hosted form backend admin — Entries list with submitted and abandoned Astro form entries, filters, and CSV export

Running in production on a live services business: versions 0.1.2 through 0.1.10 each shipped from a real-world finding there. The stories are in Hardened in production.

cool-astro-forms instruments the Astro forms you already have; it is not a form builder. It rebuilds the lead-capture layer WordPress sites assemble from premium form plugins and their paid addons as one MIT Astro integration for server-output sites, covering 5 stages:

  • Capture: form-abandonment capture, per-visitor journey tracking, and IP geolocation on every saved entry
  • Recover: a save-confirmed "progress saved" toast plus one automated follow-up email per abandoned lead
  • Convert: quote-first Stripe and PayPal payments, plus shareable /forms-pay payment links
  • Manage: the /forms-admin UI (entries, abandoned leads, payments, analytics funnel, CSV + .db export)
  • Integrate: Google Drive uploads, signed outbound webhooks, and Cloudflare Turnstile spam control

We haven't found another open-source package that offers abandonment capture for Astro or static sites. The other 4 stages ship alongside it so a captured lead has somewhere to go.

Quickstart

Every step below is executed end-to-end by scripts/verify-quickstart.mjs: a real npm pack tarball installed into a scratch Astro project, built, served, and hit with a real abandon POST that lands a row in SQLite. If this section and that script ever disagree, the script is right.

1. Install

npm install cool-astro-forms @astrojs/node

Requires Astro 7.2.8 or a later 7.x release (Astro 6 is not supported), a server-output adapter, and Node 22.12+. The install compiles better-sqlite3 natively when no prebuilt binary matches your platform; if that build fails you need Python plus a C toolchain, or skip native modules entirely with the Turso/libSQL path (docs/serverless.md).

Optional scaffold first: init writes a full .env.example (every optional env var this package reads) and appends data/ to .gitignore:

npx cool-astro-forms init

astro add cool-astro-forms also works, but it inserts a bare coolForms() call that fails validation on your next astro dev/astro build: siteId, siteUrl, and each form's notifyTo are required, with no defaults. Replace the bare call with the snippet below.

2. Configure astro.config.mjs

Every field shown here is required:

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import coolForms from 'cool-astro-forms';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  integrations: [
    coolForms({
      siteId: 'my-site',
      siteUrl: 'http://localhost:4321', // must match the origin you actually serve on — swap for your real domain once deployed
      forms: {
        contact: { notifyTo: 'owner@example.com' },
      },
    }),
  ],
});

Deploying behind a proxy or CDN? Production hosts that terminate TLS need one extra security line; see the note after step 4.

3. Tag a form

Add a data-caf="<formId>" attribute; no other markup changes are needed:

<form data-caf="contact" method="post" action="/api/contact">
  <input type="text" name="name" />
  <input type="email" name="email" />
  <button type="submit">Send</button>
</form>

4. Build and run

npm run build
node dist/server/entry.mjs

The capture route is auto-injected at /api/forms/abandon. A visitor who types into that form and leaves lands a row in data/forms.db at once and, after a short delay (abandonment.alertDelayMins, 15 minutes by default), a notification email at notifyTo. That is the entire adoption contract: one coolForms() call, one attribute. Payments, Drive uploads, lead recovery, and the admin UI stay inert until you opt in.

Deploying behind a proxy? Most production Node hosts (Hostinger, Passenger/LiteSpeed, most PaaS) terminate TLS before your Node process, so Astro sees a plain-HTTP socket and only trusts the proxy's X-Forwarded-Proto header when security.allowedDomains is set. Without it, every urlencoded form POST to a server route fails with 403 Cross-site POST form submissions are forbidden, and the first place you will meet that is the /forms-admin login form. Add your real domain once you deploy:

security: {
  allowedDomains: [{ hostname: 'example.com', protocol: 'https' }],
},

Abandonment capture keeps working either way (it posts JSON, which Astro's CSRF check ignores); the break hits admin login and payment form posts.

Email delivery (SMTP)

Package emails (abandoned-lead alerts, payment quotes and receipts, lead recovery) go out through your own SMTP server, set with environment variables. There are two modes:

Variable Credential mode (default) Relay mode (EMAIL_AUTH=ip)
EMAIL_HOST Required Required
EMAIL_PORT Required Required. Port 587 must upgrade to TLS (STARTTLS), 465 uses TLS from the start
EMAIL_USER Required Not used, and never sent
EMAIL_PASS Required Not used, and never sent
EMAIL_AUTH Leave unset ip: the relay accepts mail from your server's IP address (Google Workspace SMTP relay works this way), so no login is attempted
EMAIL_FROM Optional sender address Strongly recommended: an address on your site's domain
EMAIL_FROM_NAME Optional display name, such as Acme Bookings Same

The sender is EMAIL_FROM, then EMAIL_USER if it is an email address, then NOTIFY_EMAIL, then noreply@cool-astro-forms.local. EMAIL_FROM takes a bare address; the display name goes in EMAIL_FROM_NAME. When the sender's domain is not your siteUrl domain, the server logs one notify.from-domain-mismatch warning, because relays and receiving servers often refuse that mail. If the variables your mode needs are missing, production skips package emails and logs notify.smtp-unconfigured once; outside production the emails are printed to the log instead.

Admin pages under a Content Security Policy

With Astro's security.csp turned on, the /forms-admin pages stay styled and working: each one registers the hash of its inline stylesheet with Astro, and the payment-link copy button runs from a bundled script rather than inline handlers. If you set your own Content-Security-Policy header instead, add ADMIN_STYLE_HASH (exported from cool-astro-forms/server/admin/_shared.js) to its style-src; every admin page, the login page included, uses that one stylesheet. The copy button's script is built by Astro (a small one is inlined), so only Astro's own security.csp lists it automatically; under a hand-written header the button may not work, though copying the link by hand still does.

How it works

  1. The injected client script stages fields as the visitor types. Passwords, data-caf-ignore fields, card/CSRF-shaped names, and captcha widget tokens (cf-turnstile-response, h-captcha-response, g-recaptcha-response) are never staged.

  2. Any of the 4 capture triggers POSTs the staged fields to /api/forms/abandon.

  3. The server gates the save (origin check, rate limit, honeypot, email-or-phone requirement), dedupes repeat abandons within a 60-minute window, and writes one SQLite row with the journey trail and geolocation attached.

    Abandoned-leads view — every captured lead with its last-edited field, converted and bot-check columns

  4. After a short delay (forms.<id>.abandonment.alertDelayMins, default 15 minutes; a submission inside it cancels the alert) you get a notification email carrying the captured fields, the journey trail, the visitor's location, and the traffic source; the visitor sees a save-confirmed toast, and lead recovery (opt-in) sends one follow-up email with a link to finish.

    The default abandoned-lead notification email: captured fields, journey trail with the referrer as step one, location line, and a link to the entry

    Lead-recovery toast telling the visitor their form progress is saved and a finish link is on the way

  5. A visitor who returns and submits converts: recordSubmission() marks the entry converted and stitches the full journey onto it.

    Entry detail — the visitor's journey timeline from external referrer to captured fields, with the traffic source as step one

  6. /forms-admin/analytics turns form_started pings and captures into a funnel with abandonment rate and top drop-off fields.

    Form abandonment analytics — a 12 started, 8 abandoned, 1 submitted funnel with a 66.7% abandonment rate and top drop-off fields

  7. Payments close the loop: create a quote from any entry, or share /forms-pay?amount=200 (the payer enters an email; Stripe records them as a Customer when they pay, and their next payment is matched to it). The server recomputes every total, and only a verified provider webhook marks a payment paid. The pay page submits over fetch and hops to the provider as a plain GET, so edge bot-challenges (Cloudflare and friends) cannot break the checkout hop; see docs/payments.md section 3b.

    Shareable payment-request page at /forms-pay with payer email, optional name and a server-computed fee breakdown

Every trigger, gate, and fallback in detail, with screenshots: docs/how-it-works.md.

Features

Feature Detail
Abandonment capture 4 triggers: exit-intent, external-link click, beforeunload, tab-hidden; dedupe window; per-form overrides; owner alert deferred by abandonment.alertDelayMins (default 15, 0 = immediate) so a tab switch followed by a submit sends nothing
User-journey tracking Per-visitor page trail, shown on each entry's timeline
IP geolocation Every saved entry enriched (GEO_PROVIDER override supported); a failed lookup never blocks the save
/forms-admin UI Entries, abandoned, payments, analytics funnel, CSV + .db export; server-rendered, password-protected
Payments Quote-first Stripe Checkout + PayPal; shareable /forms-pay?amount= links; server-computed fees; fetch-first transport (edge-bot-challenge-proof checkout hop); inbound webhooks as the sole payment truth; "payment received" email to the form's notifyTo, else payments.notifyTo, else the first form's
Spam control Cloudflare Turnstile (soft-fail), honeypot, rate limiting
File uploads Google Drive via raw REST (no SDK); Drive failures fall back to email attachment, the entry always saves, and oversized files are flagged
Lead recovery One automated follow-up email per visitor, one-click unsubscribe honored forever
Outbound webhooks Signed (X-Caf-Signature): entry.submitted, entry.abandoned, payment.paid
GDPR mechanics retentionDays purge, purgeVisitor() erasure, requireConsent gating
Storage SQLite file by default; optional Turso/libSQL for serverless hosts (docs/serverless.md)

Leaving WordPress? A premium form plugin alternative for Astro

WordPress sites pay for form-abandonment capture; Astro and static sites have had no open-source equivalent. That gap is why this package exists. It is an independent, clean-room implementation, not affiliated with or endorsed by any commercial form-plugin vendor (see NOTICE). The table maps the premium form plugin features you lose leaving WordPress to what this provides:

Capability WordPress forms stack (Pro + addons) Hosted form SaaS Self-hosted OSS form backends cool-astro-forms
Abandonment / partial-entry capture Paid addon Not offered Not offered Built in
Automated lead-recovery emails Via automation addons Not offered Not offered Built in
Stripe / PayPal quote payments Paid addons Varies by plan Not offered Built in
Admin UI + analytics WordPress admin Their dashboard Minimal or none /forms-admin
Where your data lives Your WP database Their servers Your infrastructure Your SQLite file
Cost Annual per-site license Monthly plan Free Free, MIT

One deliberate trade-off: this package captures and manages form data, but it doesn't generate form markup. Bring your own form; a drag-and-drop builder is the one WordPress feature it won't replace.

Hardened in production

This package runs in production on a live services business. Versions 0.1.2 through 0.1.10 each shipped from a finding that surfaced there, and the fixes are in the defaults you install today:

  • Payments survive edge bot-challenges. A challenged navigation POST is structurally unrecoverable: the interstitial cannot replay the body. Only real browsers get challenged, so every curl and dev-environment test passes while production clicks die. The pay page submits over fetch (unchallengeable by design) and hops to checkout as a plain GET (docs/payments.md section 3b). When a Turnstile token expires mid-payment, the page recovers itself: inline explanation, refreshed widget, re-armed button.

    Payment page recovering from an expired security check: inline banner, refreshed Turnstile widget, re-enabled Pay button

  • Lead history survives redeploys. Git-deploy hosts rebuild the app directory on every release, wiping the default data/ dir. dbPath is env-configurable so the SQLite file can live outside the deploy dir; the package creates the directory and keeps the admin secret beside it.

  • Turnstile in the real world. Test sitekeys mint short dummy tokens, so submit buttons arm on any non-empty token, never on token shape. remoteip is not sent to siteverify because dual-stack visitors solve on one IP family and post on the other. Every rejection carries its Cloudflare error code, so failures diagnose themselves. A token works once and the abandonment capture spends it, so the client re-arms the widget after every send that carried one, and a host gating its own submit endpoint should still retry once on timeout-or-duplicate rather than refuse a real person. verifyTurnstile reports verified / rejected / skipped / unreachable, so "nothing was checked" never looks like "the visitor failed" — and a missing secret key warns once per process instead of running an inert gate in silence.

  • Notification emails carry the whole picture. Journey trail, IP geolocation, and traffic source arrive next to the form fields, and hosts can brand every template through templatesModule. A branded override looks like this (fictional demo brand):

    A fully branded abandoned-lead email produced by a custom templatesModule override

Docs

  • docs/how-it-works.md — the full lifecycle walkthrough with screenshots: capture triggers, the server gate, admin views, analytics, recovery, payments, storage
  • docs/payments.md — Stripe/PayPal setup, the /forms-pay contract, fee caveats, webhook receiver recipes
  • docs/drive.md — Google Drive uploads: one-time consent, the 7-day token-expiry pitfall, the fallback contract
  • docs/recovery.md — lead-recovery emails: consent modes, per-form scoping, unsubscribe mechanics
  • docs/serverless.md — Turso/libSQL storage, explicit secrets, cold-start-safe rate limiting
  • docs/gdpr.md — each retention, erasure, and consent mechanic mapped to the GDPR concept it serves
  • docs/faq.md — short self-contained answers: static sites, external services, payments, GDPR, production readiness
  • docs/comparison.md — the capability table as a standalone page, including when NOT to choose this package
  • CHANGELOG.md — release notes; 0.1.12 changes a default (payments.feeOverrides), read before upgrading

FAQ

How do I capture abandoned form entries on an Astro site?

Tag the form with data-caf and configure coolForms(). The injected client script stages typed values and POSTs them to /api/forms/abandon when any of the 4 capture triggers fires. The entry lands in SQLite with the visitor's journey trail and geolocation attached.

What is a self-hosted alternative to premium WordPress form plugins for Astro or static sites?

This package: it rebuilds the Pro-tier lead-capture layer of commercial WordPress form plugins (abandonment capture, payments, uploads, recovery emails, admin UI) as one MIT Astro integration with no per-site license and no hosted service.

How can I track partial form submissions without WordPress?

Every abandoned entry stores the fields typed so far, the last-edited field, and a form_started ping. /forms-admin/analytics turns those into a captured→converted funnel with abandonment rate and top drop-off fields.

How do I add Stripe or PayPal quote payments to an Astro contact form?

Create a pay-link from any entry in the admin, or share /forms-pay?amount=200 with no entry at all. The server recomputes every total, and payments are marked paid only by verified Stripe or PayPal webhooks (docs/payments.md).

Is there an open-source form backend with an admin UI for Astro?

Yes: /forms-admin ships in this package with entries, abandoned leads, payments, analytics, CSV and .db export, enabled by a single FORMS_ADMIN_PASSWORD env var. Server-rendered, no client framework.

How do I add file uploads to an Astro form without a SaaS?

Uploads land in your own Google Drive over raw REST (no SDK dependency). If Drive fails, files up to ~10MB fall back to email attachment; larger files are flagged and the entry still saves (docs/drive.md).

Contributing

Test and build commands, the clean-room statement, and the blocking pre-publish checklist (including a required security audit) live in CONTRIBUTING.md.

Author

Built and maintained by Nipun Arora.

License

MIT; see LICENSE.

About

The lead-ops platform for Astro forms: abandonment capture, recovery emails, quote payments, and a self-hosted admin. No external services, just SQLite and SMTP. MIT.

Topics

Resources

Contributing

Security policy

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages