邮件订阅:双重确认、一键退订(RFC 8058)、标签以及可续发的批量群发
代码11 个文件上下文约 889 个 token扫描通过
安装
$
genpm add @core/newsletter你将获得
- 源代码位于 src/lib/newsletter/,共 11 个文件。 (40.7 kB)
- AI 规则位于 src/lib/newsletter/AGENTS.md,另附 IDE 规则文件。
- 添加到 .env.example 的环境变量:NEWSLETTER_FROM, NEWSLETTER_POSTAL_ADDRESS, NEWSLETTER_SECRET, SITE_URL。
- 自动为你解析 @core/antispam, @core/contracts, @core/db, @core/email, @core/jobs, @core/rich-text。
README
此包没有 README。
约 889 个 token→ src/lib/newsletter/AGENTS.md→ .cursor/rules/genpm-core-newsletter.mdc
这正是你的 AI 在 src/lib/newsletter 中工作时读取的内容。不会向其上下文添加其他任何内容。
@core/newsletter — rules for AI agents
Purpose
Own-list newsletter: sign-ups with double opt-in and consent record, tags, one-click unsubscribe (RFC 8058 headers,
required by Gmail/Yahoo for bulk senders) plus a visible link and postal address in every email, rich-text campaigns
sent in resumable batches by @core/jobs (no duplicates), bounce/complaint suppression and GDPR erasure.
Tables: subscribers, newsletter_campaigns, newsletter_sends. No open-tracking pixels, no automations.
Map
index.ts— public API:subscribe,confirmSubscription,unsubscribe,createCampaign,sendCampaign,markUndeliverable,deleteSubscriber,newsletterAdminResources,setNewsletterMessages.newsletter.ts— logic and jobs.sender.ts— sending (Resend API with headers, or @core/email in dev).tokens.ts— signed links.adapters/hono.ts—newsletterRoutes().adapters/next.ts—subscribeRoute,confirmRoute,unsubscribeRoute.
Integration
- Env:
NEWSLETTER_FROM,NEWSLETTER_POSTAL_ADDRESS(legal requirement),NEWSLETTER_SECRET(≥ 32 chars),SITE_URL, andRESEND_API_KEYin production (other providers: implementNewsletterSenderand callsetNewsletterSender). Verify the sending domain (SPF, DKIM, DMARC) with the provider — tell the user how; you can't do it for them. - Migrations as in
src/lib/db/AGENTS.md; the @core/jobs cron must run. - Routes under
/newsletter(Honoapp.route('/newsletter', newsletterRoutes()), or the Next handlers) and three simple pages:/newsletter/confirmed,/newsletter/unsubscribed,/newsletter/invalid.confirmandunsubscribeneed GET and POST: GET only renders a minimal page with a button (mail link scanners open every URL), the button POSTs and acts; any other POST tounsubscribeis the RFC 8058 one-click (200, no redirect). Translate the page withtexts(newsletterRoutes({ texts })or the handlers' third argument). - Sign-up form:
POST /newsletter/subscribewithemail, the @core/antispam fields and an unchecked, required consent checkboxconsentwhose text version goes in a hiddenconsentVersion(withconsentVersion, the server rejects the sign-up unlessconsentis checked:consent_required). Always show the same "check your inbox" message. A plain HTML form posted from the same site (Accept: text/htmland a same-hostReferer, what browsers send) is redirected back with?newsletter=<status>#newsletter; other clients get JSON (202, or 400/429 witherror). - Translate system emails with
setNewsletterMessages((locale) => ({ confirmSubject, confirmText, unsubscribeLabel })). - Add
...newsletterAdminResources()tosrc/genpm/admin.ts; wire the provider's bounce/complaint webhook tomarkUndeliverable. - Verify: subscribe, open the confirmation link, send a campaign to yourself and unsubscribe with one click.
Conventions
- Only
active(confirmed) subscribers receive campaigns; tags segment them. - Batches claim recipients atomically (
newsletter_sends.status = 'sending',FOR UPDATE SKIP LOCKED), so concurrent batch jobs never mail the same subscriber twice; claims older than 15 min (a dead worker) are retried. - Permissions:
subscribers:read|update|delete,campaigns:read|create|update|send.
Don't
- Don't import contacts without documented consent, and never pre-check the consent box.
- Don't send campaigns from a request handler or remove the unsubscribe link/headers.
- Don't reveal whether an email is already subscribed.
# @core/newsletter — rules for AI agents
## Purpose
Own-list newsletter: sign-ups with double opt-in and consent record, tags, one-click unsubscribe (RFC 8058 headers,
required by Gmail/Yahoo for bulk senders) plus a visible link and postal address in every email, rich-text campaigns
sent in resumable batches by @core/jobs (no duplicates), bounce/complaint suppression and GDPR erasure.
Tables: `subscribers`, `newsletter_campaigns`, `newsletter_sends`. No open-tracking pixels, no automations.
## Map
- `index.ts` — public API: `subscribe`, `confirmSubscription`, `unsubscribe`, `createCampaign`, `sendCampaign`, `markUndeliverable`, `deleteSubscriber`, `newsletterAdminResources`, `setNewsletterMessages`.
- `newsletter.ts` — logic and jobs. `sender.ts` — sending (Resend API with headers, or @core/email in dev). `tokens.ts` — signed links.
- `adapters/hono.ts` — `newsletterRoutes()`. `adapters/next.ts` — `subscribeRoute`, `confirmRoute`, `unsubscribeRoute`.
## Integration
1. Env: `NEWSLETTER_FROM`, `NEWSLETTER_POSTAL_ADDRESS` (legal requirement), `NEWSLETTER_SECRET` (≥ 32 chars), `SITE_URL`,
and `RESEND_API_KEY` in production (other providers: implement `NewsletterSender` and call `setNewsletterSender`).
Verify the sending domain (SPF, DKIM, DMARC) with the provider — tell the user how; you can't do it for them.
2. Migrations as in `src/lib/db/AGENTS.md`; the @core/jobs cron must run.
3. Routes under `/newsletter` (Hono `app.route('/newsletter', newsletterRoutes())`, or the Next handlers) and three
simple pages: `/newsletter/confirmed`, `/newsletter/unsubscribed`, `/newsletter/invalid`. `confirm` and `unsubscribe`
need GET and POST: GET only renders a minimal page with a button (mail link scanners open every URL), the button
POSTs and acts; any other POST to `unsubscribe` is the RFC 8058 one-click (200, no redirect). Translate the page
with `texts` (`newsletterRoutes({ texts })` or the handlers' third argument).
4. Sign-up form: `POST /newsletter/subscribe` with `email`, the @core/antispam fields and an unchecked, required consent
checkbox `consent` whose text version goes in a hidden `consentVersion` (with `consentVersion`, the server rejects
the sign-up unless `consent` is checked: `consent_required`). Always show the same "check your inbox" message.
A plain HTML form posted from the same site (`Accept: text/html` and a same-host `Referer`, what browsers send) is
redirected back with `?newsletter=<status>#newsletter`; other clients get JSON (202, or 400/429 with `error`).
5. Translate system emails with `setNewsletterMessages((locale) => ({ confirmSubject, confirmText, unsubscribeLabel }))`.
6. Add `...newsletterAdminResources()` to `src/genpm/admin.ts`; wire the provider's bounce/complaint webhook to `markUndeliverable`.
7. Verify: subscribe, open the confirmation link, send a campaign to yourself and unsubscribe with one click.
## Conventions
- Only `active` (confirmed) subscribers receive campaigns; tags segment them.
- Batches claim recipients atomically (`newsletter_sends.status = 'sending'`, `FOR UPDATE SKIP LOCKED`), so concurrent
batch jobs never mail the same subscriber twice; claims older than 15 min (a dead worker) are retried.
- Permissions: `subscribers:read|update|delete`, `campaigns:read|create|update|send`.
## Don't
- Don't import contacts without documented consent, and never pre-check the consent box.
- Don't send campaigns from a request handler or remove the unsubscribe link/headers.
- Don't reveal whether an email is already subscribed.
应用 .genpmignore 后将被注入的确切目录树。固定于
// Panel: suscriptores (lista, etiquetas, borrado) y campañas (editar borradores, enviar, programar, cancelar).
import { and, count, desc, eq, ilike, type SQL, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { AdminContext, AdminResource } from '../contracts/index.ts';
import { getDb } from '../db/index.ts';
import { cancelCampaign, createCampaign, NewsletterError, sendCampaign, updateCampaign } from './newsletter.ts';
import { type Campaign, campaigns, type Subscriber, subscribers } from './schema.ts';
async function need(ctx: AdminContext, perm: string) {
if (!(await ctx.can(perm))) throw new NewsletterError('forbidden', `forbidden: ${perm}`);
}
const page = <T>(q: { page: number; pageSize: number }) => ({ limit: q.pageSize, offset: (Math.max(q.page, 1) - 1) * q.pageSize }) as T & { limit: number; offset: number };
export const subscribersAdminResource: AdminResource<Subscriber> = {
name: 'subscribers',
label: { singular: 'Subscriber', plural: 'Subscribers' },
group: 'Newsletter',
fields: [
{ name: 'email', label: 'Email', type: 'email', readOnly: true, list: true },
{ name: 'status', label: 'Status', type: 'select', readOnly: true, list: true, options: ['pending', 'active', 'unsubscribed', 'bounced', 'complained'].map((v) => ({ value: v, label: v })) },
{ name: 'tags', label: 'Tags', type: 'json', list: true },
{ name: 'source', label: 'Source', type: 'text', readOnly: true },
{ name: 'consentAt', label: 'Consent', type: 'datetime', readOnly: true },
],
input: z.object({ tags: z.array(z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/)).max(20) }),
title: (s) => s.email,
async list(q, ctx) {
await need(ctx, 'subscribers:read');
const conds: SQL[] = [];
if (q.filters?.status) conds.push(sql`${subscribers.status} = ${q.filters.status}`);
if (q.filters?.tag) conds.push(sql`${subscribers.tags} @> ${JSON.stringify([q.filters.tag])}::jsonb`);
if (q.search) conds.push(ilike(subscribers.email, `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%`));
const where = conds.length ? and(...conds) : undefined;
const [total] = await getDb().select({ n: count() }).from(subscribers).where(where);
const { limit, offset } = page(q);
const rows = await getDb().select().from(subscribers).where(where).orderBy(desc(subscribers.createdAt)).limit(limit).offset(offset);
return { rows, total: total?.n ?? 0 };
},
async get(id, ctx) {
await need(ctx, 'subscribers:read');
const [row] = await getDb().select().from(subscribers).where(eq(subscribers.id, id));
return row ?? null;
},
async update(id, input, ctx) {
await need(ctx, 'subscribers:update');
const { tags } = subscribersAdminResource.input.parse(input) as { tags: string[] };
const [row] = await getDb().update(subscribers).set({ tags }).where(eq(subscribers.id, id)).returning();
if (!row) throw new NewsletterError('not_found');
return row;
},
async delete(id, ctx) {
await need(ctx, 'subscribers:delete');
await getDb().delete(subscribers).where(eq(subscribers.id, id));
},
};
const CampaignForm = z.object({ subject: z.string(), preheader: z.string().optional(), body: z.unknown(), tags: z.array(z.string()).optional() });
export const campaignsAdminResource: AdminResource<Campaign> = {
name: 'campaigns',
label: { singular: 'Campaign', plural: 'Campaigns' },
group: 'Newsletter',
fields: [
{ name: 'subject', label: 'Subject', type: 'text', required: true, list: true },
{ name: 'preheader', label: 'Preview text', type: 'text' },
{ name: 'body', label: 'Content', type: 'richText', required: true },
{ name: 'tags', label: 'Only subscribers tagged', type: 'json' },
{ name: 'status', label: 'Status', type: 'select', readOnly: true, list: true, options: ['draft', 'scheduled', 'sending', 'sent', 'cancelled'].map((v) => ({ value: v, label: v })) },
{ name: 'sentCount', label: 'Sent', type: 'number', readOnly: true, list: true },
{ name: 'failedCount', label: 'Failed', type: 'number', readOnly: true },
],
input: CampaignForm,
title: (c) => c.subject,
async list(q, ctx) {
await need(ctx, 'campaigns:read');
const [total] = await getDb().select({ n: count() }).from(campaigns);
const { limit, offset } = page(q);
return { rows: await getDb().select().from(campaigns).orderBy(desc(campaigns.createdAt)).limit(limit).offset(offset), total: total?.n ?? 0 };
},
async get(id, ctx) {
await need(ctx, 'campaigns:read');
const [row] = await getDb().select().from(campaigns).where(eq(campaigns.id, id));
return row ?? null;
},
async create(input, ctx) {
await need(ctx, 'campaigns:create');
return createCampaign(input as Parameters<typeof createCampaign>[0]);
},
async update(id, input, ctx) {
await need(ctx, 'campaigns:update');
return updateCampaign(id, input as Parameters<typeof updateCampaign>[1]);
},
actions: [
{
name: 'send',
label: 'Send now',
permission: 'campaigns:send',
confirm: true,
available: (c) => c.status === 'draft' || c.status === 'scheduled',
async run(id, _i, ctx) {
await need(ctx, 'campaigns:send');
return sendCampaign(id);
},
},
{
name: 'schedule',
label: 'Schedule',
permission: 'campaigns:send',
input: z.object({ at: z.coerce.date() }),
available: (c) => c.status === 'draft',
async run(id, input, ctx) {
await need(ctx, 'campaigns:send');
const { at } = z.object({ at: z.coerce.date() }).parse(input);
if (at.getTime() <= Date.now()) throw new NewsletterError('invalid', 'date must be in the future');
return sendCampaign(id, at);
},
},
{
name: 'cancel',
label: 'Cancel',
permission: 'campaigns:send',
confirm: true,
available: (c) => c.status === 'scheduled' || c.status === 'sending',
async run(id, _i, ctx) {
await need(ctx, 'campaigns:send');
await cancelCampaign(id);
const [row] = await getDb().select().from(campaigns).where(eq(campaigns.id, id));
return row!;
},
},
],
};
export const newsletterAdminResources = () => [subscribersAdminResource, campaignsAdminResource];
此包未声明 MCP 服务器。
| 版本 | 提交 | 发布时间 | 扫描 |
|---|---|---|---|
| 1.1.0 | 48a34fe | 6小时前 | 扫描通过 |
- genpm
- @core/antispam ^1.0.0@core/contracts ^1.0.0@core/db ^1.0.0@core/email ^1.0.1@core/jobs ^1.0.0@core/rich-text ^1.0.0
- npm
- zod ^4.0.0
- 建议
- GenPM 会给出 npm 命令建议,只有你同意时才会运行。
- 被以下包使用(1)
- @core/kit-blog ^1.1.0
- 扫描
- 扫描通过 · 0 个问题
- 提交
- v1.1.0 → 48a34feb692c5ef63dc34278296cb446d534233b · 获取后已校验
- 脚本
- 无。GenPM 从不运行包中的代码。
- 许可证
- MIT
- 质量
- 100/100
- 可识别的许可证已满足
- AGENTS.md 说明了用途已满足
- AGENTS.md 包含集成步骤已满足
- AGENTS.md 列出约定或禁止事项已满足
- 包含测试已满足
- 通过安全扫描已满足
- 最近 6 个月内发布已满足
- 已验证的发布者已满足
- 摘要和关键词已满足
- 举报
- 发现问题了吗?