Formulaires et leads : en code ou depuis l'admin, anti-spam, consentement RGPD, alertes e-mail, webhooks signés, CSV
Installer
genpm add @core/formsCe que vous obtenez
- Source dans src/lib/forms/, 11 fichiers. (32,3 ko)
- Règles IA dans src/lib/forms/AGENTS.md, plus les fichiers de règles de l’IDE.
- Variables d’environnement ajoutées à .env.example : FORMS_NOTIFY_TO, FORMS_WEBHOOK_SECRET.
- Résout @core/antispam, @core/content, @core/contracts, @core/db, @core/email, @core/jobs pour vous.
README
Ce paquet n’a pas de README.
Voici exactement ce que lit votre IA quand elle travaille dans src/lib/forms. Rien d’autre n’est ajouté à son contexte.
@core/forms — rules for AI agents
Purpose
Contact and lead forms end to end: forms defined in code (defineForm with zod) or built by editors in the admin
(forms content collection), anti-spam (@core/antispam), GDPR consent with a versioned text, stored submissions with
source page and campaign parameters, and background processing (@core/jobs): team alert email, auto-reply,
HMAC-signed webhook and onSubmission handlers. CSV export, retention and erasure helpers. Table: form_submissions.
Map
index.ts— public API:defineForm,submitForm,onSubmission,formsAdminResources,exportSubmissionsCsv,pruneSubmissions,deleteSubmissionsByEmail.definitions.ts— code and admin-built definitions.submit.ts— submission and theforms.processjob.admin.ts— admin, CSV, privacy.react.ts—<Form formKey searchParams>(async server component; works without JavaScript).adapters/hono.ts—formRoutes().adapters/next.ts—formRoute(POST; HTML posts get a 303 back to the page).
Integration
- Env:
EMAIL_FROM(@core/email), optionalFORMS_NOTIFY_TO(comma-separated default recipients) andFORMS_WEBHOOK_SECRET(≥ 32 chars, only with webhooks). Migrations as insrc/lib/db/AGENTS.md; the @core/jobs cron must run. - Define code forms in a module imported at startup (e.g.
src/genpm/forms.ts):defineForm({ key: 'contact', label: 'Contact', schema: z.object({ name: z.string().min(1), email: z.email(), message: z.string().min(5) }), emailField: 'email', consent: { required: true, version: '2026-10', text: '…' } }). - Mount
formRoutes()orformRouteat/api/forms/<key>and render<Form formKey="contact" searchParams={await searchParams} />: it includes the anti-spam fields, the consent checkbox (never pre-checked) and shows the result after the 303. Custom markup: post to the same endpoint withhoneypotProps,_tsandconsent; JSON callers get{ ok, reason, errors }. In Next.js Server Actions callsubmitForm(key, formData, { headers: await headers() })instead. - Translate the messages with
labels(keys:formLabels). - Add
...formsAdminResources()tosrc/genpm/admin.ts. - Verify: submit the form, run the cron, the team receives the email and the submission appears in the admin.
Conventions
- Field names are the keys stored in
data; keep them stable (exports and webhooks depend on them). forms.processrecords each finished step inform_submissions.processed(notify,auto_reply,webhook,handler:<n>): a retry skips them, so emails and webhooks aren't repeated. KeeponSubmissionhandlers in a stable order.- Webhook receivers must verify
x-genpm-signature(t=<unix>,v1=<hmac>over"<t>.<body>") and reject old timestamps. - Ask only for the data you need; define a retention period and schedule
pruneSubmissions(days). - Permissions:
submissions:read|update|delete|export,forms:*for admin-built forms.
Don't
- Don't send emails or call webhooks inside the request;
submitFormqueues them. - Don't put submission values into HTML without escaping, or open CSV exports from other tools without the formula guard.
- Don't add hidden fields that collect data the user didn't type.
# @core/forms — rules for AI agents
## Purpose
Contact and lead forms end to end: forms defined in code (`defineForm` with zod) or built by editors in the admin
(`forms` content collection), anti-spam (@core/antispam), GDPR consent with a versioned text, stored submissions with
source page and campaign parameters, and background processing (@core/jobs): team alert email, auto-reply,
HMAC-signed webhook and `onSubmission` handlers. CSV export, retention and erasure helpers. Table: `form_submissions`.
## Map
- `index.ts` — public API: `defineForm`, `submitForm`, `onSubmission`, `formsAdminResources`, `exportSubmissionsCsv`, `pruneSubmissions`, `deleteSubmissionsByEmail`.
- `definitions.ts` — code and admin-built definitions. `submit.ts` — submission and the `forms.process` job. `admin.ts` — admin, CSV, privacy.
- `react.ts` — `<Form formKey searchParams>` (async server component; works without JavaScript).
- `adapters/hono.ts` — `formRoutes()`. `adapters/next.ts` — `formRoute` (POST; HTML posts get a 303 back to the page).
## Integration
1. Env: `EMAIL_FROM` (@core/email), optional `FORMS_NOTIFY_TO` (comma-separated default recipients) and `FORMS_WEBHOOK_SECRET` (≥ 32 chars, only with webhooks). Migrations as in `src/lib/db/AGENTS.md`; the @core/jobs cron must run.
2. Define code forms in a module imported at startup (e.g. `src/genpm/forms.ts`):
`defineForm({ key: 'contact', label: 'Contact', schema: z.object({ name: z.string().min(1), email: z.email(), message: z.string().min(5) }), emailField: 'email', consent: { required: true, version: '2026-10', text: '…' } })`.
3. Mount `formRoutes()` or `formRoute` at `/api/forms/<key>` and render `<Form formKey="contact" searchParams={await searchParams} />`:
it includes the anti-spam fields, the consent checkbox (never pre-checked) and shows the result after the 303.
Custom markup: post to the same endpoint with `honeypotProps`, `_ts` and `consent`; JSON callers get `{ ok, reason, errors }`.
In Next.js Server Actions call `submitForm(key, formData, { headers: await headers() })` instead.
4. Translate the messages with `labels` (keys: `formLabels`).
5. Add `...formsAdminResources()` to `src/genpm/admin.ts`.
6. Verify: submit the form, run the cron, the team receives the email and the submission appears in the admin.
## Conventions
- Field names are the keys stored in `data`; keep them stable (exports and webhooks depend on them).
- `forms.process` records each finished step in `form_submissions.processed` (`notify`, `auto_reply`, `webhook`,
`handler:<n>`): a retry skips them, so emails and webhooks aren't repeated. Keep `onSubmission` handlers in a stable order.
- Webhook receivers must verify `x-genpm-signature` (`t=<unix>,v1=<hmac>` over `"<t>.<body>"`) and reject old timestamps.
- Ask only for the data you need; define a retention period and schedule `pruneSubmissions(days)`.
- Permissions: `submissions:read|update|delete|export`, `forms:*` for admin-built forms.
## Don't
- Don't send emails or call webhooks inside the request; `submitForm` queues them.
- Don't put submission values into HTML without escaping, or open CSV exports from other tools without the formula guard.
- Don't add hidden fields that collect data the user didn't type.
L’arborescence exacte qui sera injectée, après .genpmignore. Épinglée à
// Panel: envíos (lista, detalle, marcar, borrar), exportación CSV y formularios editables (colección `forms`).
import { and, asc, count, desc, eq, lt, type SQL, sql } from 'drizzle-orm';
import { z } from 'zod';
import { contentAdminResource } from '../content/index.ts';
import type { AdminContext, AdminResource } from '../contracts/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { type FormSubmission, formSubmissions } from './schema.ts';
class FormsForbidden extends Error {
readonly code = 'forbidden';
}
async function need(ctx: AdminContext, action: string) {
if (!(await ctx.can(`submissions:${action}`))) throw new FormsForbidden(`forbidden: submissions:${action}`);
}
const setStatus = (status: FormSubmission['status']) => async (id: string, _input: unknown, ctx: AdminContext) => {
await need(ctx, 'update');
const [row] = await getDb().update(formSubmissions).set({ status }).where(eq(formSubmissions.id, id)).returning();
return row!;
};
export const submissionsAdminResource: AdminResource<FormSubmission> = {
name: 'submissions',
label: { singular: 'Submission', plural: 'Submissions' },
group: 'Forms',
fields: [
{ name: 'formKey', label: 'Form', type: 'text', readOnly: true, list: true },
{ name: 'status', label: 'Status', type: 'select', list: true, options: ['new', 'read', 'archived', 'spam'].map((v) => ({ value: v, label: v })) },
{ name: 'data', label: 'Data', type: 'json', readOnly: true },
{ name: 'sourcePath', label: 'Page', type: 'text', readOnly: true, list: true },
{ name: 'utm', label: 'Campaign', type: 'json', readOnly: true },
{ name: 'createdAt', label: 'Received', type: 'datetime', readOnly: true, list: true },
],
input: z.object({}),
title: (r) => `${r.formKey} · ${r.createdAt.toISOString().slice(0, 16).replace('T', ' ')}`,
async list(q, ctx) {
await need(ctx, 'read');
const conds: SQL[] = [];
if (q.filters?.form) conds.push(eq(formSubmissions.formKey, q.filters.form));
if (q.filters?.status) conds.push(sql`${formSubmissions.status} = ${q.filters.status}`);
if (q.search) conds.push(sql`${formSubmissions.data}::text ilike ${`%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%`}`);
const where = conds.length ? and(...conds) : undefined;
const [total] = await getDb().select({ n: count() }).from(formSubmissions).where(where);
const rows = await getDb().select().from(formSubmissions).where(where).orderBy(desc(formSubmissions.createdAt)).limit(q.pageSize).offset((Math.max(q.page, 1) - 1) * q.pageSize);
return { rows, total: total?.n ?? 0 };
},
async get(id, ctx) {
await need(ctx, 'read');
const [row] = await getDb().select().from(formSubmissions).where(eq(formSubmissions.id, id));
return row ?? null;
},
async delete(id, ctx) {
await need(ctx, 'delete');
await getDb().delete(formSubmissions).where(eq(formSubmissions.id, id));
},
actions: [
{ name: 'read', label: 'Mark as read', permission: 'submissions:update', available: (r) => r.status === 'new', run: setStatus('read') },
{ name: 'archive', label: 'Archive', permission: 'submissions:update', run: setStatus('archived') },
{ name: 'spam', label: 'Mark as spam', permission: 'submissions:update', run: setStatus('spam') },
],
};
export const formsAdminResources = () => [submissionsAdminResource, contentAdminResource('forms')];
/** Neutraliza fórmulas al abrir el CSV en una hoja de cálculo (=, +, -, @, tab, CR). */
const cell = (v: unknown) => {
let s = v === null || v === undefined ? '' : typeof v === 'string' ? v : JSON.stringify(v);
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return `"${s.replaceAll('"', '""')}"`;
};
/** CSV de los envíos de un formulario (columnas: fecha, estado, página, campos…). Exige `submissions:export`. */
export async function exportSubmissionsCsv(formKey: string, ctx: AdminContext, db: Executor = getDb()): Promise<string> {
await need(ctx, 'export');
const rows = await db.select().from(formSubmissions).where(eq(formSubmissions.formKey, formKey)).orderBy(asc(formSubmissions.createdAt));
const keys = [...new Set(rows.flatMap((r) => Object.keys(r.data)))];
const header = ['received', 'status', 'page', ...keys].map(cell).join(',');
const lines = rows.map((r) => [r.createdAt.toISOString(), r.status, r.sourcePath, ...keys.map((k) => r.data[k])].map(cell).join(','));
return [header, ...lines].join('\r\n');
}
/** Retención: borra envíos más antiguos que `days` (prográmalo con @core/jobs según tu política de privacidad). */
export async function pruneSubmissions(days: number, db: Executor = getDb()): Promise<number> {
const rows = await db.delete(formSubmissions).where(lt(formSubmissions.createdAt, new Date(Date.now() - days * 86_400_000))).returning({ id: formSubmissions.id });
return rows.length;
}
/** Derecho de supresión: borra los envíos que contienen este email en cualquier campo. */
export async function deleteSubmissionsByEmail(email: string, db: Executor = getDb()): Promise<number> {
const e = email.trim().toLowerCase();
const rows = await db
.delete(formSubmissions)
.where(sql`exists (select 1 from jsonb_each_text(${formSubmissions.data}) kv where lower(kv.value) = ${e})`)
.returning({ id: formSubmissions.id });
return rows.length;
}
Ce paquet ne déclare aucun serveur MCP.
| Version | Commit | Publié | Analyse |
|---|---|---|---|
| 1.1.0 | 76db1c0 | il y a 4 heures | analyse réussie |
- genpm
- @core/antispam ^1.0.0@core/content ^1.0.0@core/contracts ^1.0.0@core/db ^1.0.0@core/email ^1.0.1@core/jobs ^1.0.0
- npm
- zod ^4.0.0
- proposé
- GenPM propose la commande npm et ne l’exécute que si vous acceptez.
- Utilisé par (1)
- @core/kit-cms ^1.0.0
- analyse
- analyse réussie · 0 problème
- commit
- v1.1.0 → 76db1c0f7e6244a5ee1cab65a5d4e23826f56190 · vérifié après téléchargement
- scripts
- Aucun. GenPM n’exécute jamais le code des paquets.
- licence
- MIT
- Qualité
- 100/100
- Licence reconnuevalidé
- AGENTS.md explique son objectifvalidé
- AGENTS.md donne les étapes d’intégrationvalidé
- AGENTS.md liste conventions ou interditsvalidé
- Contient des testsvalidé
- Analyse de sécurité réussievalidé
- Publié au cours des 6 derniers moisvalidé
- Éditeur vérifiévalidé
- Résumé et mots-clésvalidé
- signalement
- Vous avez repéré un problème ?