diff --git a/CHANGELOG.md b/CHANGELOG.md index 8623fdf..e1c98f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,3 +19,5 @@ All notable changes to this project will be documented in this file. - Dashboard with summary stats and recent campaigns - Docker Compose setup for local development (Postgres, MinIO, app, worker) - Production Dockerfile with standalone Next.js output +- Embeddable signup forms with builder UI, hosted form pages at `/f/:id`, and a drop-in JS embed snippet +- Optional double opt-in flow with `pending` contact status, confirmation token, transactional confirmation email job, and a confirmation page at `/confirm/:token` diff --git a/README.md b/README.md index 8e07a6c..4c5e9fc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Self-hostable broadcast email tool. Manage contact lists, design emails with a v - **Campaign Sending** - Queue-based sending via pg-boss, with scheduling, cancellation, and per-provider rate limiting - **Open and Click Tracking** - Tracking pixel for opens, link wrapping for clicks, per-campaign analytics with charts - **Unsubscribe Handling** - One-click unsubscribe with List-Unsubscribe header support +- **Signup Forms** - Build forms in the dashboard, share a hosted page or embed a JS snippet on any site, with optional double opt-in - **REST API** - Full API with Bearer token auth for programmatic access to lists, contacts, and campaign data - **Self-Contained** - Postgres for everything (data, queue, migrations). No Redis, no external queue. Optional MinIO/S3 for file uploads - **Docker Ready** - Single `docker compose up` for local development, production-ready Dockerfile included @@ -169,6 +170,47 @@ Webhooks let Mailpost track bounces and complaints reported by the email provide 3. The endpoint will auto-confirm the subscription 4. In SES, configure a Configuration Set to publish bounce and complaint notifications to the SNS topic +## Signup Forms + +Forms turn your lists into something people can self-subscribe to. Each form is tied to a single list. Submissions create a contact in that list, respecting the global suppression list. + +### Creating a form + +1. Go to **Forms** in the sidebar and click **New Form**. +2. Pick a name and a target list. +3. In the builder, configure fields (email is required, plus any combination of text, checkbox, and select fields), set a success message or a redirect URL, and toggle double opt-in if you want a confirmation step. +4. Save. + +### Sharing the form + +Each form has two surfaces: + +- **Hosted page**: `${APP_URL}/f/`. Share the link directly or link from a navigation menu. +- **JS embed snippet**: drop the snippet into any HTML page. The form renders inline and posts back to your Mailpost instance. + +```html + +``` + +The embed renders the form via DOM injection so it inherits your site's styling. For maximum CSS isolation, add `data-mode="iframe"` and the snippet renders the hosted page inside an iframe instead. + +### Double opt-in + +When double opt-in is enabled, a submission creates a contact with status `pending` and queues a confirmation email through the chosen provider. The contact is not eligible for campaign sends until they click the link, which transitions them to `active` via `${APP_URL}/confirm/`. + +The confirmation email is authored with the same block editor used for campaigns. Use the merge tag `{{confirm_url}}` in a button block. Tracking pixels and the unsubscribe footer are deliberately omitted from confirmation emails, since the contact has not opted in yet. + +### Suppression and abuse protection + +- Submitted emails are checked against the global suppression list before any contact is created. Suppressed emails get the same success response so the form can't be used to probe membership. +- Each form is rate-limited per source IP. +- A hidden honeypot field rejects bots silently. +- Submissions use `application/x-www-form-urlencoded` so the embed avoids CORS preflights from third-party origins. + ## Using AWS S3 Instead of MinIO For production, switch from MinIO to AWS S3 by changing three variables: diff --git a/ROADMAP.md b/ROADMAP.md index ad9cd7f..d9f9b40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,7 @@ The features below build out the API surface so it's safe, observable, and compl - [ ] **Tags on contacts** [Effort: S]: Lightweight, multi-valued labels that can be applied to contacts manually or by automations. Used as filters in segments and as triggers in workflows. - [ ] **Segments** [Effort: M]: Saved filter queries against a list (e.g. "opened anything in the last 30 days, country = US"). Used as a campaign target instead of an entire list. Can be combined with tags, fields, and engagement signals. - [ ] **Double opt-in** [Effort: M]: Per-list toggle that requires new contacts to confirm via emailed link before being marked active. Confirmation page and token-based confirmation flow. -- [ ] **Embeddable signup forms** [Effort: M]: Form builder, hosted form pages, and a JS embed snippet. Submissions flow into a list and respect double opt-in if enabled. +- [x] **Embeddable signup forms** [Effort: M]: Form builder, hosted form pages, and a JS embed snippet. Submissions flow into a list and respect double opt-in if enabled. - [ ] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template. - [ ] **Asset library** [Effort: M]: Central image and file manager. Upload once, browse and drop into any campaign editor. Replaces per-campaign image uploads. - [ ] **A/B testing** [Effort: M]: Subject line and content variants on a campaign. Send variants to a test sample, pick the winner by open or click rate, and send the winner to the rest. diff --git a/app/(dashboard)/forms/[id]/builder.tsx b/app/(dashboard)/forms/[id]/builder.tsx new file mode 100644 index 0000000..9661d27 --- /dev/null +++ b/app/(dashboard)/forms/[id]/builder.tsx @@ -0,0 +1,411 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { nanoid } from 'nanoid' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useToast } from '@/components/ui/use-toast' +import type { Block, Form, FormField, FormFieldType } from '@/lib/db/schema' +import { BlockEditor } from '@/components/editor/BlockEditor' + +interface ListOption { id: string; name: string } +interface ProviderOption { id: string; name: string; isDefault: boolean } + +interface BuilderProps { + form: Form + lists: ListOption[] + providers: ProviderOption[] + appUrl: string +} + +function slugify(s: string): string { + return s + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 64) || 'field' +} + +export function Builder({ form, lists, providers, appUrl }: BuilderProps) { + const router = useRouter() + const { toast } = useToast() + const [name, setName] = useState(form.name) + const [listId, setListId] = useState(form.listId) + const [providerId, setProviderId] = useState(form.providerId ?? null) + const [fromName, setFromName] = useState(form.fromName) + const [fromEmail, setFromEmail] = useState(form.fromEmail) + const [doubleOptIn, setDoubleOptIn] = useState(form.doubleOptIn) + const [confirmationSubject, setConfirmationSubject] = useState(form.confirmationSubject) + const [confirmationTemplateJson, setConfirmationTemplateJson] = useState( + Array.isArray(form.confirmationTemplateJson) ? form.confirmationTemplateJson : [], + ) + const [successMessage, setSuccessMessage] = useState(form.successMessage) + const [redirectUrl, setRedirectUrl] = useState(form.redirectUrl ?? '') + const [fields, setFields] = useState( + Array.isArray(form.fields) && form.fields.length > 0 + ? form.fields + : [{ id: nanoid(), key: 'email', label: 'Email', type: 'email', required: true }], + ) + const [saving, setSaving] = useState(false) + + const hostedUrl = `${appUrl}/f/${form.id}` + const embedSnippet = `` + const iframeSnippet = `` + + const emailField = fields.find((f) => f.type === 'email') + + function updateField(id: string, patch: Partial) { + setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f))) + } + function removeField(id: string) { + setFields((prev) => prev.filter((f) => f.id !== id)) + } + function addField(type: FormFieldType) { + if (type === 'email') return + const baseLabel = type === 'checkbox' ? 'I agree' : type === 'select' ? 'Choose one' : 'New field' + const baseKey = + type === 'text' ? `field_${fields.length}` : type === 'select' ? `select_${fields.length}` : `consent_${fields.length}` + setFields((prev) => [ + ...prev, + { + id: nanoid(), + key: baseKey, + label: baseLabel, + type, + required: false, + options: type === 'select' ? ['Option 1'] : undefined, + }, + ]) + } + + async function handleSave() { + setSaving(true) + try { + const res = await fetch(`/api/internal/forms/${form.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + listId, + providerId: providerId || null, + fromName, + fromEmail, + fields, + doubleOptIn, + confirmationSubject, + confirmationTemplateJson, + successMessage, + redirectUrl: redirectUrl.trim() || null, + }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || 'Save failed') + } + toast({ title: 'Form saved' }) + router.refresh() + } catch (err) { + toast({ + title: 'Could not save', + description: err instanceof Error ? err.message : 'Unknown error', + variant: 'destructive', + }) + } finally { + setSaving(false) + } + } + + function copy(text: string, label: string) { + navigator.clipboard.writeText(text).then( + () => toast({ title: `${label} copied` }), + () => toast({ title: 'Copy failed', variant: 'destructive' }), + ) + } + + const previewFields = useMemo(() => fields, [fields]) + + return ( +
+
+
+ + ← Back to forms + + setName(e.target.value)} + className="text-xl font-semibold border-0 px-0 h-auto focus-visible:ring-0 shadow-none" + /> +
+ +
+ +
+
+ + + Fields + Settings + + Confirmation Email + + + + + {fields.map((field) => ( +
+
+ + {field.type} + + {field.type !== 'email' && ( + + )} +
+
+
+ + { + const label = e.target.value + updateField(field.id, { + label, + key: field.type === 'email' ? 'email' : slugify(label), + }) + }} + /> +
+
+ + updateField(field.id, { key: slugify(e.target.value) })} + disabled={field.type === 'email'} + /> +
+
+ {field.type === 'select' && ( +
+ +