Phase 11: sign-up forms - hosted pages, editor, emails, spam protection #53

Merged
vmanne-poc merged 4 commits from phase-11 into main 2026-10-15 09:32:05 -04:00
Owner

Summary

Phase 11 (v0.12.0): sign-up forms.

  • #39 Hosted page and embed. Each form has its own page at /f/:id (nginx forwards /f/ to the backend), plus an iframe snippet and a plain HTML snippet. The Allow embedding setting decides whether other sites can post to or frame the form; this is enforced by checking the Origin header and by the CSP frame-ancestors directive. After submitting, the page shows the thank-you message or redirects to an https address.
  • #40 Form editor. Tabs for Settings, Fields, Style, Double opt-in and Welcome email, with a live server-rendered preview in a sandboxed iframe and a share panel. Fields can be standard or custom (custom fields are saved to contact data), required or optional, and reordered. Settings are validated in form-settings.ts and unknown keys are dropped. New endpoints: PATCH/DELETE /forms/:id, POST /forms/preview, GET /forms/:id/embed.
  • #41 Emails. Each form has its own sender. Migration form_sender_and_token_data backfills existing forms with the project's oldest sender, which is what they used before.
    • The confirmation email is customisable (Markdown with {{ confirm_url }}) and can redirect after confirming.
    • The confirmation link opens a page, and a button confirms with a POST, so link scanners can't confirm on someone's behalf.
    • Optional welcome email, with a signed contact unsubscribe link (/api/track/u/:token) and a List-Unsubscribe header.
    • A double opt-in form with no sender refuses sign-ups with a 503 instead of silently dropping them.
    • For existing contacts, submitted details apply only after they confirm.
    • Confirmation emails are throttled to one per person per form every 2 minutes.
  • #42 Spam protection. A honeypot field, a signed fill-time ticket (minimum 2 seconds, valid for 24 hours), and an optional proof-of-work captcha: the browser's Web Crypto solves it, the server verifies it, and each puzzle works only once. Rejected submissions get the same response as accepted ones, and no email address is logged.
  • Security fix. Contact values are now HTML-escaped in email HTML, filled in after Markdown conversion. A name submitted through a public form could otherwise inject markup or links into campaigns.
  • Deleting a sender that a form uses returns 409. In Compose, PUBLIC_URL now points at nginx.

Testing

  • Backend: 72 unit and 51 e2e tests. New tests cover:
    • settings validation
    • the guard: honeypot, fast, forged, foreign and expired tickets, captcha solved, reused and missing
    • page escaping and share snippets
    • form CRUD, preview and embed
    • hosted page headers and the CSP
    • urlencoded submits: validation re-render, custom fields, spam dropped, cross-site refused when embedding is off, redirect
    • the JSON API
    • captcha end to end
    • custom confirmation and welcome emails, the confirm button flow, the throttle, deferred detail updates, unsubscribe link, no email for active or bounced contacts
    • no-sender 503
    • renderer escaping
  • Frontend: 67 tests. They cover editor fields, captcha, sender default and save body; warnings and share snippets; server errors; and the forms list.
  • Live, through nginx and Mailpit:
    • full double opt-in with a custom field
    • welcome email and its unsubscribe link
    • spam dropped
    • captcha solved and replay refused
    • headless Edge solved the captcha on the hosted page under its CSP

Not verified

  • No manual click-through of the editor in a browser; component tests cover it.
  • The captcha needs a secure context (https or localhost), which the page explains on plain http.

Closes #39
Closes #40
Closes #41
Closes #42

## Summary Phase 11 (v0.12.0): sign-up forms. - **#39 Hosted page and embed.** Each form has its own page at `/f/:id` (nginx forwards `/f/` to the backend), plus an iframe snippet and a plain HTML snippet. The **Allow embedding** setting decides whether other sites can post to or frame the form; this is enforced by checking the Origin header and by the CSP `frame-ancestors` directive. After submitting, the page shows the thank-you message or redirects to an https address. - **#40 Form editor.** Tabs for Settings, Fields, Style, Double opt-in and Welcome email, with a live server-rendered preview in a sandboxed iframe and a share panel. Fields can be standard or custom (custom fields are saved to contact data), required or optional, and reordered. Settings are validated in `form-settings.ts` and unknown keys are dropped. New endpoints: `PATCH`/`DELETE /forms/:id`, `POST /forms/preview`, `GET /forms/:id/embed`. - **#41 Emails.** Each form has its own sender. Migration `form_sender_and_token_data` backfills existing forms with the project's oldest sender, which is what they used before. - The confirmation email is customisable (Markdown with `{{ confirm_url }}`) and can redirect after confirming. - The confirmation link opens a page, and a button confirms with a POST, so link scanners can't confirm on someone's behalf. - Optional welcome email, with a signed contact unsubscribe link (`/api/track/u/:token`) and a List-Unsubscribe header. - A double opt-in form with no sender refuses sign-ups with a 503 instead of silently dropping them. - For existing contacts, submitted details apply only after they confirm. - Confirmation emails are throttled to one per person per form every 2 minutes. - **#42 Spam protection.** A honeypot field, a signed fill-time ticket (minimum 2 seconds, valid for 24 hours), and an optional proof-of-work captcha: the browser's Web Crypto solves it, the server verifies it, and each puzzle works only once. Rejected submissions get the same response as accepted ones, and no email address is logged. - **Security fix.** Contact values are now HTML-escaped in email HTML, filled in after Markdown conversion. A name submitted through a public form could otherwise inject markup or links into campaigns. - Deleting a sender that a form uses returns 409. In Compose, `PUBLIC_URL` now points at nginx. ## Testing - **Backend: 72 unit and 51 e2e tests.** New tests cover: - settings validation - the guard: honeypot, fast, forged, foreign and expired tickets, captcha solved, reused and missing - page escaping and share snippets - form CRUD, preview and embed - hosted page headers and the CSP - urlencoded submits: validation re-render, custom fields, spam dropped, cross-site refused when embedding is off, redirect - the JSON API - captcha end to end - custom confirmation and welcome emails, the confirm button flow, the throttle, deferred detail updates, unsubscribe link, no email for active or bounced contacts - no-sender 503 - renderer escaping - **Frontend: 67 tests.** They cover editor fields, captcha, sender default and save body; warnings and share snippets; server errors; and the forms list. - **Live, through nginx and Mailpit:** - full double opt-in with a custom field - welcome email and its unsubscribe link - spam dropped - captcha solved and replay refused - headless Edge solved the captcha on the hosted page under its CSP ## Not verified - No manual click-through of the editor in a browser; component tests cover it. - The captcha needs a secure context (https or localhost), which the page explains on plain http. Closes #39 Closes #40 Closes #41 Closes #42
Names and other contact values (which public sign-up forms can supply) are HTML-escaped and filled in after Markdown conversion, so they can't add markup, links, or formatting. Plain-text versions are unchanged. Adds renderSimpleEmail for Postak's own emails.
Hosted form at /f/:id (nginx forwards /f/), JSON submit, share snippets; PATCH/DELETE forms with validated settings (fields incl. custom data, wording, thank-you message or https redirect, style, embedding, captcha) and a per-form sender (migration form_sender_and_token_data backfills existing forms); confirmation page with a button (POST confirms), custom confirmation and welcome emails, contact-token unsubscribe links; honeypot, signed fill-time ticket, optional proof-of-work captcha; cross-site posts refused unless embedding is allowed; CSP on form pages. Deleting a sender used by a form returns 409.

Refs #39, #40, #41, #42
Forms as cards with Open, Copy link, and Edit; editor with Settings, Fields (standard and custom, required, order), Style, Double opt-in (sender, confirmation email, redirect), and Welcome email tabs; live server-rendered preview in a sandboxed iframe; share panel with link, iframe, and plain HTML snippets; warnings for a missing sender or confirmation link.

Closes #39

Closes #40

Closes #41

Closes #42
vmanne-poc deleted branch phase-11 2026-10-15 09:32:05 -04:00
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
vmanne-poc/Postak!53
No description provided.