Vibecode Formspree
track this build6 phases, 12 steps, beginner friendly0%An endpoint that accepts a form post, stores it, emails you and redirects back is the canonical small server. The product sells convenience, spam filtering tuned across thousands of forms, and not running a server.
You are building a lean indie version of Formspree. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Formspree · indie build A form backend for static sites: point any plain HTML form at your endpoint and get the submission stored, emailed to you and optionally posted to a webhook, with a honeypot, rate limits and an origin allowlist so strangers cannot post to it. Static site stays static; you run one small server. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | accept, store, redirect | | Email | SMTP via nodemailer, fire-and-forget | a dead mail host must never lose a submission | | Hosting | A VPS behind Caddy | the endpoint must be public HTTPS | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The static sites whose forms will post here, with their origins** · free - Why: Phase 2 refuses any origin not in the allowlist. - Get it: List each site's https origin exactly. - [ ] **SMTP credentials for notifications** · free tiers exist - Why: Phase 3 emails each submission. - Get it: Fastmail, Postmark, Resend SMTP or your mail host: host, port, user, app password. - [ ] **A webhook URL (optional)** (optional) · free - Why: The other notification path. - Get it: webhook.site while testing. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: A public address you own, so links you share never break when a provider changes. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. - spam filtering tuned on someone else's traffic - the integrations (Slack, Zapier, Google Sheets) - file uploads to their storage - not running a server for a static site If one of those is essential to you, that is the reason to keep paying for Formspree, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Formspree The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form backend for static sites like Formspree. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with node:http and node:sqlite. No Express, no framework, one process. - Plain HTML forms post to it with no JavaScript required. SQLite in WAL mode at a path from .env. ### Data model (create this before Phase 1) - forms: id, slug (unique, short), name, redirect_url, notify_email, webhook_url, created_at - submissions: id, form_id, received_at, fields (JSON), ip_hash, user_agent_class, spam (bool) - One row per submission with the fields as JSON, because every form has different fields and you do not control the markup that posts to you. ### Phase 1 · Accept a post Build: POST /f/:slug accepting application/x-www-form-urlencoded and multipart (text fields only for now). Store the fields verbatim as JSON, cap the body at 64 kB, answer with a 303 redirect to the form's redirect_url, or a plain thank-you page when none is set. Unknown slugs answer 404. If the request asks for JSON (Accept header), answer JSON instead of redirecting. Done when: a blank HTML page with a form pointing at your endpoint submits with JavaScript disabled and lands on the thank-you, one row exists with the fields intact, and a 100 kB body is refused. Do not build yet: email, spam, uploads, admin. ### Phase 2 · Spam controls Build: a honeypot convention (any field named _gotcha filled means drop silently but still redirect), a per-IP rate limit of 10 per hour in SQLite, an origin check against an allowlist of domains per form, and a coarse user-agent bot filter. Flag rather than delete: set spam = true so a false positive is recoverable. Done when: a filled honeypot redirects normally and stores a row flagged spam, the eleventh submission in an hour is refused, and a post from a disallowed origin is refused. ### Phase 3 · Notifications Build: on each non-spam submission, send an email over SMTP from .env to notify_email with the fields as a readable table, and POST the JSON to webhook_url when set. Both fire-and-forget with a timeout · a dead SMTP host must never make the visitor wait or lose their submission. Done when: a submission still stores and redirects with SMTP unreachable, and a real send arrives readable in a phone mail client. ### Phase 4 · File uploads Build: multipart file fields streamed to uploads/<form-slug>/ under a generated name, 10 MB cap enforced while streaming, MIME checked against the decoded header, images stripped of EXIF. Reference the stored path in the submission JSON. Done when: an 11 MB file is refused before it is fully read, a renamed executable is refused, and two files with the same original name coexist. ### Phase 5 · Admin Build: /admin behind basic auth from .env: create forms and copy the paste-in HTML snippet, list submissions per form with the spam ones in a separate tab, a restore-from-spam button, and streamed CSV export. Done when: a form goes from created to receiving a real submission using only the snippet from the admin, and a 10,000-row export does not grow memory. ### Phase 6 · Deploy Build: a /healthz endpoint, a nightly backup of the database and the uploads directory, a systemd unit, and the README. Done when: a restore brings back both rows and files. ### Out of scope (and why) - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. Yours is a honeypot and a rate limit; say so. - Not running a server. That is the actual thing Formspree sells to static-site owners, and this build gives it back. ### README must contain - The paste-in HTML snippet with the _gotcha honeypot field, verbatim. - The origin allowlist step, because forgetting it means anyone can post to your endpoint. ===== AGENTS.md ===== # Agent instructions · Formspree indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SMTP via nodemailer, fire-and-forget, A VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ===== BUILD_PLAN.md ===== # Build plan · Formspree A form backend for static sites: point any plain HTML form at your endpoint and get the submission stored, emailed to you and optionally posted to a webhook, with a honeypot, rate limits and an origin allowlist so strangers cannot post to it. Static site stays static; you run one small server. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Accept a post A plain HTML form on another site submits and lands on a thank-you. ### Steps 1. Create the project and tables forms (id, slug, name, redirect_url, notify_email, webhook_url), submissions (id, form_id, received_at, fields JSON, ip_hash, user_agent_class, spam). Fields as JSON because you do not control the markup that posts to you. ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` 2. Implement POST /f/:slug for urlencoded and multipart text fields, 64 kB cap, 303 to redirect_url or a plain thank-you; JSON when the Accept header asks ### Done when - [ ] A blank HTML page's form submits with JavaScript disabled and lands on the thank-you - [ ] One row with the fields intact - [ ] A 100 kB body is refused ## Phase 2 · Spam controls Honeypot, rate limit, origin allowlist, bot filter, all flagging rather than deleting. ### Steps 1. Honeypot field _gotcha: filled means store with spam true and redirect normally 2. Per-IP limit of 10 per hour in SQLite; refuse origins not in ALLOWED_ORIGINS; coarse bot user-agent filter ### Done when - [ ] A filled honeypot redirects and stores a spam row - [ ] The eleventh post in an hour is refused - [ ] A disallowed origin gets 403 ## Phase 3 · Notifications Email and webhook that never make the visitor wait. ### Steps 1. Send an email over SMTP_URL with the fields as a table, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. POST the JSON to webhook_url when set, same rules ### Done when - [ ] A submission stores and redirects with SMTP unreachable - [ ] A real send arrives readable on a phone ## Phase 4 · File uploads Safe uploads with a generated name and a streaming cap. ### Steps 1. Stream multipart files to UPLOAD_DIR/<slug>/ with a 10 MB cap while streaming and a MIME sniff ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Strip EXIF from images and never web-serve UPLOAD_DIR directly ### Done when - [ ] An 11 MB file is refused before it is fully read - [ ] A renamed executable is refused - [ ] Two same-named uploads coexist ## Phase 5 · Admin Forms, the snippet, submissions with spam separate, CSV. ### Steps 1. Basic-auth /admin: create forms and copy the paste-in HTML snippet with the honeypot field 2. Submissions per form with a spam tab, restore-from-spam, and streamed CSV ### Done when - [ ] A form goes from created to a real submission using only the snippet - [ ] A 10,000-row export does not grow memory ## Phase 6 · Deploy HTTPS, backups covering uploads, README. ### Steps 1. /healthz, systemd, Caddy, nightly backup of data/ Files: `deploy/formback.service`, `Caddyfile` 2. README: the snippet verbatim, the allowlist step, where data lives Files: `README.md` ### Done when - [ ] A restore brings back rows and files - [ ] The README reaches a working form ## Not in this build - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. ## After v1, if you want it - A Slack adapter - Per-form autoresponder email to the submitter ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Uploaded files per form. UPLOAD_DIR=./data/uploads # Required. Comma-separated origins permitted to post. ALLOWED_ORIGINS=https://yoursite.com # Required · secret. From your SMTP provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Required. Where submissions are emailed. NOTIFY_TO=you@yourdomain.com # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
You are building a lean indie version of Formspree. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Formspree · indie build A form backend for static sites: point any plain HTML form at your endpoint and get the submission stored, emailed to you and optionally posted to a webhook, with a honeypot, rate limits and an origin allowlist so strangers cannot post to it. Static site stays static; you run one small server. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | accept, store, redirect | | Email | SMTP via nodemailer, fire-and-forget | a dead mail host must never lose a submission | | Hosting | A VPS behind Caddy | the endpoint must be public HTTPS | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The static sites whose forms will post here, with their origins** · free - Why: Phase 2 refuses any origin not in the allowlist. - Get it: List each site's https origin exactly. - [ ] **SMTP credentials for notifications** · free tiers exist - Why: Phase 3 emails each submission. - Get it: Fastmail, Postmark, Resend SMTP or your mail host: host, port, user, app password. - [ ] **A webhook URL (optional)** (optional) · free - Why: The other notification path. - Get it: webhook.site while testing. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: A public address you own, so links you share never break when a provider changes. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. - spam filtering tuned on someone else's traffic - the integrations (Slack, Zapier, Google Sheets) - file uploads to their storage - not running a server for a static site If one of those is essential to you, that is the reason to keep paying for Formspree, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Formspree The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form backend for static sites like Formspree. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with node:http and node:sqlite. No Express, no framework, one process. - Plain HTML forms post to it with no JavaScript required. SQLite in WAL mode at a path from .env. ### Data model (create this before Phase 1) - forms: id, slug (unique, short), name, redirect_url, notify_email, webhook_url, created_at - submissions: id, form_id, received_at, fields (JSON), ip_hash, user_agent_class, spam (bool) - One row per submission with the fields as JSON, because every form has different fields and you do not control the markup that posts to you. ### Phase 1 · Accept a post Build: POST /f/:slug accepting application/x-www-form-urlencoded and multipart (text fields only for now). Store the fields verbatim as JSON, cap the body at 64 kB, answer with a 303 redirect to the form's redirect_url, or a plain thank-you page when none is set. Unknown slugs answer 404. If the request asks for JSON (Accept header), answer JSON instead of redirecting. Done when: a blank HTML page with a form pointing at your endpoint submits with JavaScript disabled and lands on the thank-you, one row exists with the fields intact, and a 100 kB body is refused. Do not build yet: email, spam, uploads, admin. ### Phase 2 · Spam controls Build: a honeypot convention (any field named _gotcha filled means drop silently but still redirect), a per-IP rate limit of 10 per hour in SQLite, an origin check against an allowlist of domains per form, and a coarse user-agent bot filter. Flag rather than delete: set spam = true so a false positive is recoverable. Done when: a filled honeypot redirects normally and stores a row flagged spam, the eleventh submission in an hour is refused, and a post from a disallowed origin is refused. ### Phase 3 · Notifications Build: on each non-spam submission, send an email over SMTP from .env to notify_email with the fields as a readable table, and POST the JSON to webhook_url when set. Both fire-and-forget with a timeout · a dead SMTP host must never make the visitor wait or lose their submission. Done when: a submission still stores and redirects with SMTP unreachable, and a real send arrives readable in a phone mail client. ### Phase 4 · File uploads Build: multipart file fields streamed to uploads/<form-slug>/ under a generated name, 10 MB cap enforced while streaming, MIME checked against the decoded header, images stripped of EXIF. Reference the stored path in the submission JSON. Done when: an 11 MB file is refused before it is fully read, a renamed executable is refused, and two files with the same original name coexist. ### Phase 5 · Admin Build: /admin behind basic auth from .env: create forms and copy the paste-in HTML snippet, list submissions per form with the spam ones in a separate tab, a restore-from-spam button, and streamed CSV export. Done when: a form goes from created to receiving a real submission using only the snippet from the admin, and a 10,000-row export does not grow memory. ### Phase 6 · Deploy Build: a /healthz endpoint, a nightly backup of the database and the uploads directory, a systemd unit, and the README. Done when: a restore brings back both rows and files. ### Out of scope (and why) - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. Yours is a honeypot and a rate limit; say so. - Not running a server. That is the actual thing Formspree sells to static-site owners, and this build gives it back. ### README must contain - The paste-in HTML snippet with the _gotcha honeypot field, verbatim. - The origin allowlist step, because forgetting it means anyone can post to your endpoint. ===== AGENTS.md ===== # Agent instructions · Formspree indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SMTP via nodemailer, fire-and-forget, A VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ===== BUILD_PLAN.md ===== # Build plan · Formspree A form backend for static sites: point any plain HTML form at your endpoint and get the submission stored, emailed to you and optionally posted to a webhook, with a honeypot, rate limits and an origin allowlist so strangers cannot post to it. Static site stays static; you run one small server. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Accept a post A plain HTML form on another site submits and lands on a thank-you. ### Steps 1. Create the project and tables forms (id, slug, name, redirect_url, notify_email, webhook_url), submissions (id, form_id, received_at, fields JSON, ip_hash, user_agent_class, spam). Fields as JSON because you do not control the markup that posts to you. ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` 2. Implement POST /f/:slug for urlencoded and multipart text fields, 64 kB cap, 303 to redirect_url or a plain thank-you; JSON when the Accept header asks ### Done when - [ ] A blank HTML page's form submits with JavaScript disabled and lands on the thank-you - [ ] One row with the fields intact - [ ] A 100 kB body is refused ## Phase 2 · Spam controls Honeypot, rate limit, origin allowlist, bot filter, all flagging rather than deleting. ### Steps 1. Honeypot field _gotcha: filled means store with spam true and redirect normally 2. Per-IP limit of 10 per hour in SQLite; refuse origins not in ALLOWED_ORIGINS; coarse bot user-agent filter ### Done when - [ ] A filled honeypot redirects and stores a spam row - [ ] The eleventh post in an hour is refused - [ ] A disallowed origin gets 403 ## Phase 3 · Notifications Email and webhook that never make the visitor wait. ### Steps 1. Send an email over SMTP_URL with the fields as a table, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. POST the JSON to webhook_url when set, same rules ### Done when - [ ] A submission stores and redirects with SMTP unreachable - [ ] A real send arrives readable on a phone ## Phase 4 · File uploads Safe uploads with a generated name and a streaming cap. ### Steps 1. Stream multipart files to UPLOAD_DIR/<slug>/ with a 10 MB cap while streaming and a MIME sniff ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Strip EXIF from images and never web-serve UPLOAD_DIR directly ### Done when - [ ] An 11 MB file is refused before it is fully read - [ ] A renamed executable is refused - [ ] Two same-named uploads coexist ## Phase 5 · Admin Forms, the snippet, submissions with spam separate, CSV. ### Steps 1. Basic-auth /admin: create forms and copy the paste-in HTML snippet with the honeypot field 2. Submissions per form with a spam tab, restore-from-spam, and streamed CSV ### Done when - [ ] A form goes from created to a real submission using only the snippet - [ ] A 10,000-row export does not grow memory ## Phase 6 · Deploy HTTPS, backups covering uploads, README. ### Steps 1. /healthz, systemd, Caddy, nightly backup of data/ Files: `deploy/formback.service`, `Caddyfile` 2. README: the snippet verbatim, the allowlist step, where data lives Files: `README.md` ### Done when - [ ] A restore brings back rows and files - [ ] The README reaches a working form ## Not in this build - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. ## After v1, if you want it - A Slack adapter - Per-form autoresponder email to the submitter ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Uploaded files per form. UPLOAD_DIR=./data/uploads # Required. Comma-separated origins permitted to post. ALLOWED_ORIGINS=https://yoursite.com # Required · secret. From your SMTP provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Required. Where submissions are emailed. NOTIFY_TO=you@yourdomain.com # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
You are building a production product version of Formspree. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Formspree · product brief ## Problem An endpoint that accepts a form post, stores it, emails you and redirects back is the canonical small server. The product sells convenience, spam filtering tuned across thousands of forms, and not running a server. ## Product outcome A form backend for every static site you ship, with spam flagged not lost and notifications that cannot break a submission. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a small always-on host - SMTP credentials for notifications ## Explicit non-goals for v1 - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. - spam filtering tuned on someone else's traffic - the integrations (Slack, Zapier, Google Sheets) - file uploads to their storage - not running a server for a static site ## Success criteria - Every spam control verified - Notifications verified with SMTP down - One restore drill performed ===== BRIEF.md ===== # Build brief · Formspree The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form backend for static sites like Formspree. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with node:http and node:sqlite. No Express, no framework, one process. - Plain HTML forms post to it with no JavaScript required. SQLite in WAL mode at a path from .env. ### Data model (create this before Phase 1) - forms: id, slug (unique, short), name, redirect_url, notify_email, webhook_url, created_at - submissions: id, form_id, received_at, fields (JSON), ip_hash, user_agent_class, spam (bool) - One row per submission with the fields as JSON, because every form has different fields and you do not control the markup that posts to you. ### Phase 1 · Accept a post Build: POST /f/:slug accepting application/x-www-form-urlencoded and multipart (text fields only for now). Store the fields verbatim as JSON, cap the body at 64 kB, answer with a 303 redirect to the form's redirect_url, or a plain thank-you page when none is set. Unknown slugs answer 404. If the request asks for JSON (Accept header), answer JSON instead of redirecting. Done when: a blank HTML page with a form pointing at your endpoint submits with JavaScript disabled and lands on the thank-you, one row exists with the fields intact, and a 100 kB body is refused. Do not build yet: email, spam, uploads, admin. ### Phase 2 · Spam controls Build: a honeypot convention (any field named _gotcha filled means drop silently but still redirect), a per-IP rate limit of 10 per hour in SQLite, an origin check against an allowlist of domains per form, and a coarse user-agent bot filter. Flag rather than delete: set spam = true so a false positive is recoverable. Done when: a filled honeypot redirects normally and stores a row flagged spam, the eleventh submission in an hour is refused, and a post from a disallowed origin is refused. ### Phase 3 · Notifications Build: on each non-spam submission, send an email over SMTP from .env to notify_email with the fields as a readable table, and POST the JSON to webhook_url when set. Both fire-and-forget with a timeout · a dead SMTP host must never make the visitor wait or lose their submission. Done when: a submission still stores and redirects with SMTP unreachable, and a real send arrives readable in a phone mail client. ### Phase 4 · File uploads Build: multipart file fields streamed to uploads/<form-slug>/ under a generated name, 10 MB cap enforced while streaming, MIME checked against the decoded header, images stripped of EXIF. Reference the stored path in the submission JSON. Done when: an 11 MB file is refused before it is fully read, a renamed executable is refused, and two files with the same original name coexist. ### Phase 5 · Admin Build: /admin behind basic auth from .env: create forms and copy the paste-in HTML snippet, list submissions per form with the spam ones in a separate tab, a restore-from-spam button, and streamed CSV export. Done when: a form goes from created to receiving a real submission using only the snippet from the admin, and a 10,000-row export does not grow memory. ### Phase 6 · Deploy Build: a /healthz endpoint, a nightly backup of the database and the uploads directory, a systemd unit, and the README. Done when: a restore brings back both rows and files. ### Out of scope (and why) - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. Yours is a honeypot and a rate limit; say so. - Not running a server. That is the actual thing Formspree sells to static-site owners, and this build gives it back. ### README must contain - The paste-in HTML snippet with the _gotcha honeypot field, verbatim. - The origin allowlist step, because forgetting it means anyone can post to your endpoint. ===== ARCHITECTURE.md ===== # Architecture · Formspree ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | accept, store, redirect | | Email | SMTP via nodemailer, fire-and-forget | a dead mail host must never lose a submission | | Hosting | A VPS behind Caddy | the endpoint must be public HTTPS | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | /f routes and spam flags | Any handler writing the same rows | | Notify | email and webhook | Add Slack as an adapter | | Files | streaming uploads | Object storage behind the same functions | | Admin | forms, snippet, export | Any UI | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port. - `DATABASE_PATH` · required · SQLite file. - `UPLOAD_DIR` · required · Uploaded files per form. - `ALLOWED_ORIGINS` · required · Comma-separated origins permitted to post. - `SMTP_URL` · required, secret · From your SMTP provider. - `NOTIFY_TO` · required · Where submissions are emailed. - `SITE_URL` · required · Public base URL. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Formspree product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SMTP via nodemailer, fire-and-forget, A VPS behind Caddy. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ===== MILESTONES.md ===== # Delivery milestones · Formspree Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Accept a post A plain HTML form on another site submits and lands on a thank-you. ### Steps 1. Create the project and tables forms (id, slug, name, redirect_url, notify_email, webhook_url), submissions (id, form_id, received_at, fields JSON, ip_hash, user_agent_class, spam). Fields as JSON because you do not control the markup that posts to you. ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` 2. Implement POST /f/:slug for urlencoded and multipart text fields, 64 kB cap, 303 to redirect_url or a plain thank-you; JSON when the Accept header asks ### Done when - [ ] A blank HTML page's form submits with JavaScript disabled and lands on the thank-you - [ ] One row with the fields intact - [ ] A 100 kB body is refused ## M2 · Spam controls Honeypot, rate limit, origin allowlist, bot filter, all flagging rather than deleting. ### Steps 1. Honeypot field _gotcha: filled means store with spam true and redirect normally 2. Per-IP limit of 10 per hour in SQLite; refuse origins not in ALLOWED_ORIGINS; coarse bot user-agent filter ### Done when - [ ] A filled honeypot redirects and stores a spam row - [ ] The eleventh post in an hour is refused - [ ] A disallowed origin gets 403 ## M3 · Notifications Email and webhook that never make the visitor wait. ### Steps 1. Send an email over SMTP_URL with the fields as a table, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. POST the JSON to webhook_url when set, same rules ### Done when - [ ] A submission stores and redirects with SMTP unreachable - [ ] A real send arrives readable on a phone ## M4 · File uploads Safe uploads with a generated name and a streaming cap. ### Steps 1. Stream multipart files to UPLOAD_DIR/<slug>/ with a 10 MB cap while streaming and a MIME sniff ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Strip EXIF from images and never web-serve UPLOAD_DIR directly ### Done when - [ ] An 11 MB file is refused before it is fully read - [ ] A renamed executable is refused - [ ] Two same-named uploads coexist ## M5 · Admin Forms, the snippet, submissions with spam separate, CSV. ### Steps 1. Basic-auth /admin: create forms and copy the paste-in HTML snippet with the honeypot field 2. Submissions per form with a spam tab, restore-from-spam, and streamed CSV ### Done when - [ ] A form goes from created to a real submission using only the snippet - [ ] A 10,000-row export does not grow memory ## M6 · Deploy HTTPS, backups covering uploads, README. ### Steps 1. /healthz, systemd, Caddy, nightly backup of data/ Files: `deploy/formback.service`, `Caddyfile` 2. README: the snippet verbatim, the allowlist step, where data lives Files: `README.md` ### Done when - [ ] A restore brings back rows and files - [ ] The README reaches a working form ## M7 · Operate it like a product (production only) Only for the product-builder path: know when the endpoint is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443 ===== OPERATIONS.md ===== # Operations · Formspree ## Backup tar of data/ nightly off the box. ## Restore Extract, start, check one form. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime on /healthz; a spike in spam rows. ## Incident checklist Spam wave: tighten limits, tag the pattern. Leaked SMTP: rotate. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every spam control verified - [ ] Notifications verified with SMTP down - [ ] One restore drill performed ## Launch constraint Do not market omitted Formspree capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Uploaded files per form. UPLOAD_DIR=./data/uploads # Required. Comma-separated origins permitted to post. ALLOWED_ORIGINS=https://yoursite.com # Required · secret. From your SMTP provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Required. Where submissions are emailed. NOTIFY_TO=you@yourdomain.com # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# Formspree · indie build A form backend for static sites: point any plain HTML form at your endpoint and get the submission stored, emailed to you and optionally posted to a webhook, with a honeypot, rate limits and an origin allowlist so strangers cannot post to it. Static site stays static; you run one small server. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | accept, store, redirect | | Email | SMTP via nodemailer, fire-and-forget | a dead mail host must never lose a submission | | Hosting | A VPS behind Caddy | the endpoint must be public HTTPS | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The static sites whose forms will post here, with their origins** · free - Why: Phase 2 refuses any origin not in the allowlist. - Get it: List each site's https origin exactly. - [ ] **SMTP credentials for notifications** · free tiers exist - Why: Phase 3 emails each submission. - Get it: Fastmail, Postmark, Resend SMTP or your mail host: host, port, user, app password. - [ ] **A webhook URL (optional)** (optional) · free - Why: The other notification path. - Get it: webhook.site while testing. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: A public address you own, so links you share never break when a provider changes. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. - spam filtering tuned on someone else's traffic - the integrations (Slack, Zapier, Google Sheets) - file uploads to their storage - not running a server for a static site If one of those is essential to you, that is the reason to keep paying for Formspree, and the README should say so rather than pretend.
# Build brief · Formspree The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form backend for static sites like Formspree. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with node:http and node:sqlite. No Express, no framework, one process. - Plain HTML forms post to it with no JavaScript required. SQLite in WAL mode at a path from .env. ### Data model (create this before Phase 1) - forms: id, slug (unique, short), name, redirect_url, notify_email, webhook_url, created_at - submissions: id, form_id, received_at, fields (JSON), ip_hash, user_agent_class, spam (bool) - One row per submission with the fields as JSON, because every form has different fields and you do not control the markup that posts to you. ### Phase 1 · Accept a post Build: POST /f/:slug accepting application/x-www-form-urlencoded and multipart (text fields only for now). Store the fields verbatim as JSON, cap the body at 64 kB, answer with a 303 redirect to the form's redirect_url, or a plain thank-you page when none is set. Unknown slugs answer 404. If the request asks for JSON (Accept header), answer JSON instead of redirecting. Done when: a blank HTML page with a form pointing at your endpoint submits with JavaScript disabled and lands on the thank-you, one row exists with the fields intact, and a 100 kB body is refused. Do not build yet: email, spam, uploads, admin. ### Phase 2 · Spam controls Build: a honeypot convention (any field named _gotcha filled means drop silently but still redirect), a per-IP rate limit of 10 per hour in SQLite, an origin check against an allowlist of domains per form, and a coarse user-agent bot filter. Flag rather than delete: set spam = true so a false positive is recoverable. Done when: a filled honeypot redirects normally and stores a row flagged spam, the eleventh submission in an hour is refused, and a post from a disallowed origin is refused. ### Phase 3 · Notifications Build: on each non-spam submission, send an email over SMTP from .env to notify_email with the fields as a readable table, and POST the JSON to webhook_url when set. Both fire-and-forget with a timeout · a dead SMTP host must never make the visitor wait or lose their submission. Done when: a submission still stores and redirects with SMTP unreachable, and a real send arrives readable in a phone mail client. ### Phase 4 · File uploads Build: multipart file fields streamed to uploads/<form-slug>/ under a generated name, 10 MB cap enforced while streaming, MIME checked against the decoded header, images stripped of EXIF. Reference the stored path in the submission JSON. Done when: an 11 MB file is refused before it is fully read, a renamed executable is refused, and two files with the same original name coexist. ### Phase 5 · Admin Build: /admin behind basic auth from .env: create forms and copy the paste-in HTML snippet, list submissions per form with the spam ones in a separate tab, a restore-from-spam button, and streamed CSV export. Done when: a form goes from created to receiving a real submission using only the snippet from the admin, and a 10,000-row export does not grow memory. ### Phase 6 · Deploy Build: a /healthz endpoint, a nightly backup of the database and the uploads directory, a systemd unit, and the README. Done when: a restore brings back both rows and files. ### Out of scope (and why) - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. Yours is a honeypot and a rate limit; say so. - Not running a server. That is the actual thing Formspree sells to static-site owners, and this build gives it back. ### README must contain - The paste-in HTML snippet with the _gotcha honeypot field, verbatim. - The origin allowlist step, because forgetting it means anyone can post to your endpoint.
# Agent instructions · Formspree indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SMTP via nodemailer, fire-and-forget, A VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
# Build plan · Formspree A form backend for static sites: point any plain HTML form at your endpoint and get the submission stored, emailed to you and optionally posted to a webhook, with a honeypot, rate limits and an origin allowlist so strangers cannot post to it. Static site stays static; you run one small server. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Accept a post A plain HTML form on another site submits and lands on a thank-you. ### Steps 1. Create the project and tables forms (id, slug, name, redirect_url, notify_email, webhook_url), submissions (id, form_id, received_at, fields JSON, ip_hash, user_agent_class, spam). Fields as JSON because you do not control the markup that posts to you. ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` 2. Implement POST /f/:slug for urlencoded and multipart text fields, 64 kB cap, 303 to redirect_url or a plain thank-you; JSON when the Accept header asks ### Done when - [ ] A blank HTML page's form submits with JavaScript disabled and lands on the thank-you - [ ] One row with the fields intact - [ ] A 100 kB body is refused ## Phase 2 · Spam controls Honeypot, rate limit, origin allowlist, bot filter, all flagging rather than deleting. ### Steps 1. Honeypot field _gotcha: filled means store with spam true and redirect normally 2. Per-IP limit of 10 per hour in SQLite; refuse origins not in ALLOWED_ORIGINS; coarse bot user-agent filter ### Done when - [ ] A filled honeypot redirects and stores a spam row - [ ] The eleventh post in an hour is refused - [ ] A disallowed origin gets 403 ## Phase 3 · Notifications Email and webhook that never make the visitor wait. ### Steps 1. Send an email over SMTP_URL with the fields as a table, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. POST the JSON to webhook_url when set, same rules ### Done when - [ ] A submission stores and redirects with SMTP unreachable - [ ] A real send arrives readable on a phone ## Phase 4 · File uploads Safe uploads with a generated name and a streaming cap. ### Steps 1. Stream multipart files to UPLOAD_DIR/<slug>/ with a 10 MB cap while streaming and a MIME sniff ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Strip EXIF from images and never web-serve UPLOAD_DIR directly ### Done when - [ ] An 11 MB file is refused before it is fully read - [ ] A renamed executable is refused - [ ] Two same-named uploads coexist ## Phase 5 · Admin Forms, the snippet, submissions with spam separate, CSV. ### Steps 1. Basic-auth /admin: create forms and copy the paste-in HTML snippet with the honeypot field 2. Submissions per form with a spam tab, restore-from-spam, and streamed CSV ### Done when - [ ] A form goes from created to a real submission using only the snippet - [ ] A 10,000-row export does not grow memory ## Phase 6 · Deploy HTTPS, backups covering uploads, README. ### Steps 1. /healthz, systemd, Caddy, nightly backup of data/ Files: `deploy/formback.service`, `Caddyfile` 2. README: the snippet verbatim, the allowlist step, where data lives Files: `README.md` ### Done when - [ ] A restore brings back rows and files - [ ] The README reaches a working form ## Not in this build - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. ## After v1, if you want it - A Slack adapter - Per-form autoresponder email to the submitter
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Uploaded files per form. UPLOAD_DIR=./data/uploads # Required. Comma-separated origins permitted to post. ALLOWED_ORIGINS=https://yoursite.com # Required · secret. From your SMTP provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Required. Where submissions are emailed. NOTIFY_TO=you@yourdomain.com # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# Formspree · product brief ## Problem An endpoint that accepts a form post, stores it, emails you and redirects back is the canonical small server. The product sells convenience, spam filtering tuned across thousands of forms, and not running a server. ## Product outcome A form backend for every static site you ship, with spam flagged not lost and notifications that cannot break a submission. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a small always-on host - SMTP credentials for notifications ## Explicit non-goals for v1 - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. - Not running a server, which is what Formspree actually sells. - spam filtering tuned on someone else's traffic - the integrations (Slack, Zapier, Google Sheets) - file uploads to their storage - not running a server for a static site ## Success criteria - Every spam control verified - Notifications verified with SMTP down - One restore drill performed
# Build brief · Formspree The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a form backend for static sites like Formspree. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with node:http and node:sqlite. No Express, no framework, one process. - Plain HTML forms post to it with no JavaScript required. SQLite in WAL mode at a path from .env. ### Data model (create this before Phase 1) - forms: id, slug (unique, short), name, redirect_url, notify_email, webhook_url, created_at - submissions: id, form_id, received_at, fields (JSON), ip_hash, user_agent_class, spam (bool) - One row per submission with the fields as JSON, because every form has different fields and you do not control the markup that posts to you. ### Phase 1 · Accept a post Build: POST /f/:slug accepting application/x-www-form-urlencoded and multipart (text fields only for now). Store the fields verbatim as JSON, cap the body at 64 kB, answer with a 303 redirect to the form's redirect_url, or a plain thank-you page when none is set. Unknown slugs answer 404. If the request asks for JSON (Accept header), answer JSON instead of redirecting. Done when: a blank HTML page with a form pointing at your endpoint submits with JavaScript disabled and lands on the thank-you, one row exists with the fields intact, and a 100 kB body is refused. Do not build yet: email, spam, uploads, admin. ### Phase 2 · Spam controls Build: a honeypot convention (any field named _gotcha filled means drop silently but still redirect), a per-IP rate limit of 10 per hour in SQLite, an origin check against an allowlist of domains per form, and a coarse user-agent bot filter. Flag rather than delete: set spam = true so a false positive is recoverable. Done when: a filled honeypot redirects normally and stores a row flagged spam, the eleventh submission in an hour is refused, and a post from a disallowed origin is refused. ### Phase 3 · Notifications Build: on each non-spam submission, send an email over SMTP from .env to notify_email with the fields as a readable table, and POST the JSON to webhook_url when set. Both fire-and-forget with a timeout · a dead SMTP host must never make the visitor wait or lose their submission. Done when: a submission still stores and redirects with SMTP unreachable, and a real send arrives readable in a phone mail client. ### Phase 4 · File uploads Build: multipart file fields streamed to uploads/<form-slug>/ under a generated name, 10 MB cap enforced while streaming, MIME checked against the decoded header, images stripped of EXIF. Reference the stored path in the submission JSON. Done when: an 11 MB file is refused before it is fully read, a renamed executable is refused, and two files with the same original name coexist. ### Phase 5 · Admin Build: /admin behind basic auth from .env: create forms and copy the paste-in HTML snippet, list submissions per form with the spam ones in a separate tab, a restore-from-spam button, and streamed CSV export. Done when: a form goes from created to receiving a real submission using only the snippet from the admin, and a 10,000-row export does not grow memory. ### Phase 6 · Deploy Build: a /healthz endpoint, a nightly backup of the database and the uploads directory, a systemd unit, and the README. Done when: a restore brings back both rows and files. ### Out of scope (and why) - The integrations gallery and hosted file storage. - Spam filtering tuned on someone else's traffic. Yours is a honeypot and a rate limit; say so. - Not running a server. That is the actual thing Formspree sells to static-site owners, and this build gives it back. ### README must contain - The paste-in HTML snippet with the _gotcha honeypot field, verbatim. - The origin allowlist step, because forgetting it means anyone can post to your endpoint.
# Architecture · Formspree ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | accept, store, redirect | | Email | SMTP via nodemailer, fire-and-forget | a dead mail host must never lose a submission | | Hosting | A VPS behind Caddy | the endpoint must be public HTTPS | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | /f routes and spam flags | Any handler writing the same rows | | Notify | email and webhook | Add Slack as an adapter | | Files | streaming uploads | Object storage behind the same functions | | Admin | forms, snippet, export | Any UI | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port. - `DATABASE_PATH` · required · SQLite file. - `UPLOAD_DIR` · required · Uploaded files per form. - `ALLOWED_ORIGINS` · required · Comma-separated origins permitted to post. - `SMTP_URL` · required, secret · From your SMTP provider. - `NOTIFY_TO` · required · Where submissions are emailed. - `SITE_URL` · required · Public base URL. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Formspree product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SMTP via nodemailer, fire-and-forget, A VPS behind Caddy. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone.
# Delivery milestones · Formspree Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Accept a post A plain HTML form on another site submits and lands on a thank-you. ### Steps 1. Create the project and tables forms (id, slug, name, redirect_url, notify_email, webhook_url), submissions (id, form_id, received_at, fields JSON, ip_hash, user_agent_class, spam). Fields as JSON because you do not control the markup that posts to you. ```sh mkdir formback && cd formback && git init && npm init -y && npm pkg set type=module mkdir -p data/uploads && cp .env.example .env ``` 2. Implement POST /f/:slug for urlencoded and multipart text fields, 64 kB cap, 303 to redirect_url or a plain thank-you; JSON when the Accept header asks ### Done when - [ ] A blank HTML page's form submits with JavaScript disabled and lands on the thank-you - [ ] One row with the fields intact - [ ] A 100 kB body is refused ## M2 · Spam controls Honeypot, rate limit, origin allowlist, bot filter, all flagging rather than deleting. ### Steps 1. Honeypot field _gotcha: filled means store with spam true and redirect normally 2. Per-IP limit of 10 per hour in SQLite; refuse origins not in ALLOWED_ORIGINS; coarse bot user-agent filter ### Done when - [ ] A filled honeypot redirects and stores a spam row - [ ] The eleventh post in an hour is refused - [ ] A disallowed origin gets 403 ## M3 · Notifications Email and webhook that never make the visitor wait. ### Steps 1. Send an email over SMTP_URL with the fields as a table, fire-and-forget with a timeout ```sh npm install nodemailer@6 ``` 2. POST the JSON to webhook_url when set, same rules ### Done when - [ ] A submission stores and redirects with SMTP unreachable - [ ] A real send arrives readable on a phone ## M4 · File uploads Safe uploads with a generated name and a streaming cap. ### Steps 1. Stream multipart files to UPLOAD_DIR/<slug>/ with a 10 MB cap while streaming and a MIME sniff ```sh npm install busboy@1 sharp@0.35.3 ``` 2. Strip EXIF from images and never web-serve UPLOAD_DIR directly ### Done when - [ ] An 11 MB file is refused before it is fully read - [ ] A renamed executable is refused - [ ] Two same-named uploads coexist ## M5 · Admin Forms, the snippet, submissions with spam separate, CSV. ### Steps 1. Basic-auth /admin: create forms and copy the paste-in HTML snippet with the honeypot field 2. Submissions per form with a spam tab, restore-from-spam, and streamed CSV ### Done when - [ ] A form goes from created to a real submission using only the snippet - [ ] A 10,000-row export does not grow memory ## M6 · Deploy HTTPS, backups covering uploads, README. ### Steps 1. /healthz, systemd, Caddy, nightly backup of data/ Files: `deploy/formback.service`, `Caddyfile` 2. README: the snippet verbatim, the allowlist step, where data lives Files: `README.md` ### Done when - [ ] A restore brings back rows and files - [ ] The README reaches a working form ## M7 · Operate it like a product (production only) Only for the product-builder path: know when the endpoint is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443
# Operations · Formspree ## Backup tar of data/ nightly off the box. ## Restore Extract, start, check one form. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime on /healthz; a spike in spam rows. ## Incident checklist Spam wave: tighten limits, tag the pattern. Leaked SMTP: rotate. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every spam control verified - [ ] Notifications verified with SMTP down - [ ] One restore drill performed ## Launch constraint Do not market omitted Formspree capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/forms.db # Required. Uploaded files per form. UPLOAD_DIR=./data/uploads # Required. Comma-separated origins permitted to post. ALLOWED_ORIGINS=https://yoursite.com # Required · secret. From your SMTP provider. SMTP_URL=smtps://user:pass@smtp.fastmail.com:465 # Required. Where submissions are emailed. NOTIFY_TO=you@yourdomain.com # Required. Public base URL. SITE_URL=https://forms.yourdomain.com # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
$ choose a build depth, inspect the files, then open the complete pack in your agent
The whole appeal of a static site is having no server. Paying $15 to keep it that way is rational, and their spam model has seen more bots than yours ever will.
xspam filtering tuned on someone else's traffic
xthe integrations (Slack, Zapier, Google Sheets)
xfile uploads to their storage
xnot running a server for a static site
Formspree pricing
personal$15/mo · monthly flat · $180/yr
free tierThe free plan takes 50 submissions a month with a 30-day archive.
verified 2026-09-04 · source ↗
Is Formspree free?
The free plan takes 50 submissions a month with a 30-day archive. Paid is Personal at $15/mo (checked 2026-09-04).
Vibecode Formspree
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Formspree replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Formspree cost?
Formspree costs about $15/month (Personal, checked 2026-09-04), which is $180 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Formspree?
Honestly: spam filtering tuned on someone else's traffic; the integrations (Slack, Zapier, Google Sheets); file uploads to their storage; not running a server for a static site. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Formspree?
Yes: Formbricks (open-source forms and surveys platform). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.