フォームとリード獲得:コードまたは管理画面で作成、スパム対策、GDPR 同意、メール通知、署名付き Webhook、CSV
インストール
genpm add @core/forms含まれるもの
- src/lib/forms/ にソースコード(11 ファイル)。 (32.3 KB)
- src/lib/forms/AGENTS.md に AI ルール、加えて IDE 用のルールファイル。
- .env.example に追加される環境変数: FORMS_NOTIFY_TO, FORMS_WEBHOOK_SECRET。
- @core/antispam, @core/content, @core/contracts, @core/db, @core/email, @core/jobs を自動で解決します。
README
このパッケージには README がありません。
これは AI が src/lib/forms で作業するときに読む内容そのものです。それ以外はコンテキストに追加されません。
@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.
.genpmignore 適用後に組み込まれる正確なツリーです。固定先:
// Envío de formularios: antispam → validación → consentimiento → guardar → avisos en segundo plano (@core/jobs).
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyHuman } from '../antispam/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { getEmailProvider } from '../email/index.ts';
import { defineJob } from '../jobs/index.ts';
import { type FormDefinition, getForm } from './definitions.ts';
import { type FormSubmission, formSubmissions } from './schema.ts';
export const CONSENT_FIELD = 'consent';
const RESERVED = new Set(['website', '_ts', 'cf-turnstile-response', 'h-captcha-response', CONSENT_FIELD]);
const TRACKING = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid', 'ttclid'];
export type SubmitResult =
| { ok: true; id: string }
| { ok: false; reason: 'not_found' | 'spam' | 'rate_limited' | 'consent_required'; retryAfterSeconds?: number }
| { ok: false; reason: 'invalid'; errors: Array<{ field: string; message: string }> };
type Handler = (submission: FormSubmission, form: FormDefinition) => Promise<void> | void;
const handlers = new Map<string, Handler[]>();
/** Reacciona a envíos (alta en newsletter, CRM…). Se ejecuta en segundo plano, con reintentos. */
export function onSubmission(formKey: string, handler: Handler): void {
handlers.set(formKey, [...(handlers.get(formKey) ?? []), handler]);
}
/** Solo tests. */
export const clearSubmissionHandlers = () => handlers.clear();
function fieldsOf(input: FormData | Record<string, unknown>): Record<string, unknown> {
if (!(input instanceof FormData)) return { ...input };
const out: Record<string, unknown> = {};
input.forEach((v, k) => {
if (typeof v === 'string') out[k] = v;
});
return out;
}
/** UTM y click IDs de la URL de origen; la ruta se guarda sin query. */
export function sourceInfo(sourceUrl: string | null | undefined): { sourcePath: string | null; utm: Record<string, string> | null } {
if (!sourceUrl) return { sourcePath: null, utm: null };
try {
const u = new URL(sourceUrl);
const utm = Object.fromEntries(TRACKING.flatMap((k) => (u.searchParams.get(k) ? [[k, u.searchParams.get(k)!.slice(0, 200)]] : [])));
return { sourcePath: u.pathname.slice(0, 500), utm: Object.keys(utm).length ? utm : null };
} catch {
return { sourcePath: null, utm: null };
}
}
export async function submitForm(
formKey: string,
input: FormData | Record<string, unknown>,
ctx: { headers: Headers; sourceUrl?: string | null; skipAntispam?: boolean },
db: Executor = getDb(),
): Promise<SubmitResult> {
const form = await getForm(formKey);
if (!form) return { ok: false, reason: 'not_found' };
const fields = fieldsOf(input);
if (!ctx.skipAntispam) {
const human = await verifyHuman(ctx.headers, fields, { scope: `forms:${formKey}`, limit: 5, windowMs: 600_000 }, db);
if (!human.ok)
return human.reason === 'rate_limited'
? { ok: false, reason: 'rate_limited', retryAfterSeconds: human.retryAfterSeconds }
: { ok: false, reason: 'spam' };
}
const data = Object.fromEntries(Object.entries(fields).filter(([k]) => !RESERVED.has(k)));
const parsed = form.schema.safeParse(data);
if (!parsed.success)
return { ok: false, reason: 'invalid', errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) };
const consented = [true, 'on', 'true', 'yes'].includes(fields[CONSENT_FIELD] as string | boolean);
if (form.consent?.required && !consented) return { ok: false, reason: 'consent_required' };
const [row] = await db
.insert(formSubmissions)
.values({ formKey, data: parsed.data as Record<string, unknown>, consentVersion: consented && form.consent ? form.consent.version : null, ...sourceInfo(ctx.sourceUrl) })
.returning();
await processSubmissionJob.enqueue({ submissionId: row!.id }, { dedupeKey: `forms:${row!.id}` }, db);
return { ok: true, id: row!.id };
}
const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
const show = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v));
async function sign(secret: string, payload: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return [...new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(payload)))].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Firma del webhook: `x-genpm-signature: t=<unix>,v1=<hmac_sha256("<t>.<body>")>`. */
export async function webhookSignature(body: string, t: number, secret = process.env.FORMS_WEBHOOK_SECRET ?? ''): Promise<string> {
if (secret.length < 32) throw new Error('FORMS_WEBHOOK_SECRET must be set (>= 32 chars) to use webhooks');
return `t=${t},v1=${await sign(secret, `${t}.${body}`)}`;
}
/**
* Aviso al equipo, respuesta automática, webhook y `onSubmission`. Cada paso se anota en `processed` al terminar:
* si uno falla y la tarea se reintenta, los ya hechos no se repiten (no se reenvían emails ni se repite el webhook).
*/
export const processSubmissionJob = defineJob(
'forms.process',
z.object({ submissionId: z.string() }),
async ({ submissionId }, { signal }) => {
const db = getDb();
const [sub] = await db.select().from(formSubmissions).where(eq(formSubmissions.id, submissionId));
if (!sub) return;
const form = await getForm(sub.formKey);
if (!form) return;
const done = new Set(sub.processed ?? []);
const step = async (name: string, run: () => Promise<void>) => {
if (done.has(name)) return;
await run();
done.add(name);
await db
.update(formSubmissions)
.set({ processed: sql`${formSubmissions.processed} || ${JSON.stringify([name])}::jsonb` })
.where(eq(formSubmissions.id, sub.id));
};
const from = process.env.EMAIL_FROM;
const notify = form.notify?.length ? form.notify : (process.env.FORMS_NOTIFY_TO ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const sender = form.emailField ? z.email().safeParse(sub.data[form.emailField]) : null;
const lines = Object.entries(sub.data).map(([k, v]) => [k, show(v)] as const);
if (from && notify.length)
await step('notify', async () => {
await getEmailProvider().send({
from,
to: notify,
subject: `New ${form.label} submission`,
text: lines.map(([k, v]) => `${k}: ${v}`).join('\n'),
html: `<table>${lines.map(([k, v]) => `<tr><th align="left">${esc(k)}</th><td>${esc(v).replaceAll('\n', '<br>')}</td></tr>`).join('')}</table>`,
...(sender?.success && { replyTo: sender.data }),
});
});
if (from && form.autoReply && sender?.success) {
const reply = form.autoReply;
await step('auto_reply', async () => {
await getEmailProvider().send({ from, to: sender.data, subject: reply.subject, text: reply.text, html: `<p>${esc(reply.text).replaceAll('\n', '<br>')}</p>` });
});
}
if (form.webhook) {
const url = form.webhook;
await step('webhook', async () => {
const body = JSON.stringify({ id: sub.id, form: sub.formKey, data: sub.data, createdAt: sub.createdAt.toISOString(), utm: sub.utm });
const t = Math.floor(Date.now() / 1000);
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-genpm-signature': await webhookSignature(body, t) },
body,
redirect: 'error',
signal,
});
if (!res.ok) throw new Error(`webhook responded ${res.status}`);
});
}
for (const [i, h] of (handlers.get(sub.formKey) ?? []).entries()) await step(`handler:${i}`, async () => void (await h(sub, form)));
},
{ maxAttempts: 5, timeoutMs: 30_000 },
);
このパッケージは MCP サーバーを宣言していません。
| バージョン | コミット | 公開日 | スキャン |
|---|---|---|---|
| 1.1.0 | 76db1c0 | 5 時間前 | スキャン合格 |
- 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
- 提案
- GenPM は npm コマンドを提案し、あなたが承認した場合にのみ実行します。
- 利用元(1)
- @core/kit-cms ^1.0.0
- スキャン
- スキャン合格 · 指摘 0 件
- コミット
- v1.1.0 → 76db1c0f7e6244a5ee1cab65a5d4e23826f56190 · 取得後に検証済み
- スクリプト
- なし。GenPM はパッケージのコードを実行しません。
- ライセンス
- MIT
- 品質
- 100/100
- 認識されたライセンス達成
- AGENTS.md に目的の説明がある達成
- AGENTS.md に統合手順がある達成
- AGENTS.md に規約や禁止事項がある達成
- テストを含む達成
- セキュリティスキャンに合格達成
- 過去 6 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?