뉴스레터: 더블 옵트인, 원클릭 구독 해지(RFC 8058), 태그, 재개 가능한 일괄 캠페인 발송
설치
genpm add @core/newsletter포함 내용
- src/lib/newsletter/에 소스 코드, 파일 11개. (40.7kB)
- src/lib/newsletter/AGENTS.md에 AI 규칙, 그리고 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가 없습니다.
이것이 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 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Tablas de @core/newsletter. Las recoge drizzle-kit vía src/lib/db/drizzle.config.ts.
import { index, integer, jsonb, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
import { primaryId, timestamps } from '../db/index.ts';
import type { Doc } from '../rich-text/index.ts';
const ts = (name: string) => timestamp(name, { withTimezone: true, mode: 'date' });
export const subscribers = pgTable(
'subscribers',
{
id: primaryId('nsb'),
/** Normalizado (minúsculas, sin espacios). Necesario para enviar; protégelo como dato personal. */
email: text('email').notNull().unique(),
status: text('status', { enum: ['pending', 'active', 'unsubscribed', 'bounced', 'complained'] })
.notNull()
.default('pending'),
tags: jsonb('tags').$type<string[]>().notNull().default([]),
locale: text('locale'),
/** Dónde se suscribió (`footer`, `blog-post`, `landing:spring`). */
source: text('source'),
/** Versión del texto de consentimiento aceptado y fechas (prueba RGPD). */
consentVersion: text('consent_version'),
consentAt: ts('consent_at'),
confirmedAt: ts('confirmed_at'),
unsubscribedAt: ts('unsubscribed_at'),
...timestamps,
},
(t) => [index('subscribers_status_idx').on(t.status)],
);
export const campaigns = pgTable('newsletter_campaigns', {
id: primaryId('cmp'),
subject: text('subject').notNull(),
preheader: text('preheader'),
body: jsonb('body').$type<Doc>().notNull(),
/** Solo suscriptores con alguna de estas etiquetas; vacío = todos los activos. */
tags: jsonb('tags').$type<string[]>().notNull().default([]),
status: text('status', { enum: ['draft', 'scheduled', 'sending', 'sent', 'cancelled'] })
.notNull()
.default('draft'),
scheduledAt: ts('scheduled_at'),
sentAt: ts('sent_at'),
sentCount: integer('sent_count').notNull().default(0),
failedCount: integer('failed_count').notNull().default(0),
...timestamps,
});
/** Un envío por (campaña, suscriptor): hace los lotes reanudables e impide duplicados. */
export const campaignSends = pgTable(
'newsletter_sends',
{
campaignId: text('campaign_id')
.notNull()
.references(() => campaigns.id, { onDelete: 'cascade' }),
subscriberId: text('subscriber_id')
.notNull()
.references(() => subscribers.id, { onDelete: 'cascade' }),
/** `sending`: reservado por un lote (UPDATE … SKIP LOCKED), para que dos lotes a la vez no envíen dos veces. */
status: text('status', { enum: ['queued', 'sending', 'sent', 'failed'] })
.notNull()
.default('queued'),
/** Cuándo lo reservó el lote; si el proceso murió, se libera pasado `STALE_CLAIM_MS`. */
claimedAt: ts('claimed_at'),
error: text('error'),
sentAt: ts('sent_at'),
},
(t) => [primaryKey({ columns: [t.campaignId, t.subscriberId] }), index('newsletter_sends_status_idx').on(t.campaignId, t.status)],
);
export type Subscriber = typeof subscribers.$inferSelect;
export type Campaign = typeof campaigns.$inferSelect;
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 48a34fe | 4시간 전 | 검사 통과 |
- 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개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?