뉴스레터: 더블 옵트인, 원클릭 구독 해지(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 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Altas con doble opt-in, bajas en un clic y campañas enviadas por lotes reanudables con @core/jobs.
import { and, eq, inArray, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyHuman } from '../antispam/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { defineJob, jobs } from '../jobs/index.ts';
import { type Doc, toHtml, toPlainText } from '../rich-text/index.ts';
import { type Campaign, campaignSends, campaigns, type Subscriber, subscribers } from './schema.ts';
import { getNewsletterSender } from './sender.ts';
import { makeToken, readToken } from './tokens.ts';
export class NewsletterError extends Error {
constructor(
readonly code: 'invalid' | 'spam' | 'rate_limited' | 'not_found' | 'invalid_state' | 'invalid_token' | 'forbidden' | 'consent_required',
message: string = code,
) {
super(message);
this.name = 'NewsletterError';
}
}
export const normalizeEmail = (e: string) => e.trim().toLowerCase();
const Tag = z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/);
/** Textos de los emails del sistema por idioma (por defecto inglés). Sustitúyelos con `setNewsletterMessages`. */
export type NewsletterMessages = {
confirmSubject: string;
confirmText: (link: string) => string;
unsubscribeLabel: string;
};
const EN: NewsletterMessages = {
confirmSubject: 'Confirm your subscription',
confirmText: (link) => `Please confirm your subscription by opening this link:\n\n${link}\n\nIf you didn't ask for it, ignore this email.`,
unsubscribeLabel: 'Unsubscribe',
};
let messagesFor: (locale: string | null) => NewsletterMessages = () => EN;
export const setNewsletterMessages = (fn: (locale: string | null) => Partial<NewsletterMessages>) => {
messagesFor = (l) => ({ ...EN, ...fn(l) });
};
function env(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set (see .env.example)`);
return v;
}
const siteUrl = (path: string) => new URL(path, env('SITE_URL')).toString();
export const unsubscribeUrl = async (id: string) => siteUrl(`/newsletter/unsubscribe?token=${await makeToken(id, 'unsubscribe')}`);
export const confirmUrl = async (id: string) => siteUrl(`/newsletter/confirm?token=${await makeToken(id, 'confirm')}`);
/**
* Alta (o reactivación) en estado `pending` y email de confirmación. Si ya está activo no hace nada; la respuesta
* es la misma en todos los casos para no revelar quién está suscrito.
*/
export async function subscribe(
input: { email: string; tags?: string[]; source?: string; locale?: string; consentVersion?: string },
ctx: { headers: Headers; fields: FormData | Record<string, unknown>; skipAntispam?: boolean },
db: Executor = getDb(),
): Promise<void> {
if (!ctx.skipAntispam) {
const human = await verifyHuman(ctx.headers, ctx.fields, { scope: 'newsletter', limit: 5, windowMs: 3_600_000 }, db);
if (!human.ok) throw new NewsletterError(human.reason === 'rate_limited' ? 'rate_limited' : 'spam');
}
const email = z.email().max(254).safeParse(normalizeEmail(input.email));
const tags = z.array(Tag).max(20).safeParse(input.tags ?? []);
if (!email.success || !tags.success) throw new NewsletterError('invalid');
const [existing] = await db.select().from(subscribers).where(eq(subscribers.email, email.data));
if (existing?.status === 'active') {
const merged = [...new Set([...existing.tags, ...tags.data])];
if (merged.length !== existing.tags.length) await db.update(subscribers).set({ tags: merged }).where(eq(subscribers.id, existing.id));
return;
}
if (existing?.status === 'complained') return; // marcó como spam: no se le vuelve a escribir
const values = {
status: 'pending' as const,
tags: [...new Set([...(existing?.tags ?? []), ...tags.data])],
locale: input.locale ?? existing?.locale ?? null,
source: (input.source ?? existing?.source ?? null)?.slice(0, 80) ?? null,
consentVersion: input.consentVersion ?? null,
consentAt: new Date(),
unsubscribedAt: null,
};
const [row] = existing
? await db.update(subscribers).set(values).where(eq(subscribers.id, existing.id)).returning()
: await db.insert(subscribers).values({ email: email.data, ...values }).returning();
await sendConfirmationJob.enqueue({ subscriberId: row!.id }, { dedupeKey: `newsletter.confirm:${row!.id}` }, db);
}
export const sendConfirmationJob = defineJob('newsletter.confirm', z.object({ subscriberId: z.string() }), async ({ subscriberId }) => {
const [s] = await getDb().select().from(subscribers).where(eq(subscribers.id, subscriberId));
if (!s || s.status !== 'pending') return;
const msg = messagesFor(s.locale);
const link = await confirmUrl(s.id);
const text = msg.confirmText(link);
await getNewsletterSender().send({
from: env('NEWSLETTER_FROM'),
to: s.email,
subject: msg.confirmSubject,
text,
html: `<p>${esc(text).replaceAll('\n', '<br>').replace(esc(link), `<a href="${esc(link)}">${esc(link)}</a>`)}</p>`,
headers: {},
});
});
export async function confirmSubscription(token: string | null, db: Executor = getDb()): Promise<Subscriber> {
const id = await readToken(token, 'confirm');
if (!id) throw new NewsletterError('invalid_token');
const [row] = await db
.update(subscribers)
.set({ status: 'active', confirmedAt: new Date() })
.where(and(eq(subscribers.id, id), inArray(subscribers.status, ['pending', 'active'])))
.returning();
if (!row) throw new NewsletterError('invalid_state');
return row;
}
export async function unsubscribe(token: string | null, db: Executor = getDb()): Promise<void> {
const id = await readToken(token, 'unsubscribe');
if (!id) throw new NewsletterError('invalid_token');
await db
.update(subscribers)
.set({ status: 'unsubscribed', unsubscribedAt: new Date() })
.where(and(eq(subscribers.id, id), inArray(subscribers.status, ['pending', 'active'])));
}
/** Rebotes y quejas del proveedor (desde su webhook): no se vuelve a enviar a esa dirección. */
export async function markUndeliverable(email: string, kind: 'bounced' | 'complained', db: Executor = getDb()): Promise<void> {
await db.update(subscribers).set({ status: kind }).where(eq(subscribers.email, normalizeEmail(email)));
}
/** Derecho de supresión. */
export async function deleteSubscriber(email: string, db: Executor = getDb()): Promise<boolean> {
const rows = await db.delete(subscribers).where(eq(subscribers.email, normalizeEmail(email))).returning({ id: subscribers.id });
return rows.length > 0;
}
const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
/** Email de una campaña para un suscriptor: cuerpo, pie con dirección postal y baja, y cabeceras RFC 8058. */
export async function renderCampaign(c: Pick<Campaign, 'subject' | 'preheader' | 'body'>, s: Pick<Subscriber, 'id' | 'email' | 'locale'>) {
const unsub = await unsubscribeUrl(s.id);
const address = env('NEWSLETTER_POSTAL_ADDRESS');
const label = messagesFor(s.locale).unsubscribeLabel;
const host = new URL(env('SITE_URL')).host;
const preheader = c.preheader ? `<div style="display:none;max-height:0;overflow:hidden">${esc(c.preheader)}</div>` : '';
return {
subject: c.subject,
html: `${preheader}${toHtml(c.body as Doc, { siteHost: host })}<hr><p style="font-size:12px;color:#666">${esc(address)}<br><a href="${esc(unsub)}">${esc(label)}</a></p>`,
text: `${toPlainText(c.body as Doc)}\n\n--\n${address}\n${label}: ${unsub}`,
headers: { 'List-Unsubscribe': `<${unsub}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' },
};
}
const CampaignInput = z.object({
subject: z.string().min(1).max(200),
preheader: z.string().max(200).optional(),
body: z.custom<Doc>((v) => typeof v === 'object' && v !== null && (v as Doc).type === 'doc'),
tags: z.array(Tag).max(20).default([]),
});
export async function createCampaign(input: z.input<typeof CampaignInput>, db: Executor = getDb()): Promise<Campaign> {
const data = CampaignInput.parse(input);
const [row] = await db.insert(campaigns).values({ ...data, preheader: data.preheader ?? null }).returning();
return row!;
}
export async function updateCampaign(id: string, input: z.input<typeof CampaignInput>, db: Executor = getDb()): Promise<Campaign> {
const data = CampaignInput.parse(input);
const [row] = await db.update(campaigns).set({ ...data, preheader: data.preheader ?? null }).where(and(eq(campaigns.id, id), eq(campaigns.status, 'draft'))).returning();
if (!row) throw new NewsletterError('invalid_state', 'only draft campaigns can be edited');
return row;
}
/** Empieza a enviar (o programa para `at`). Solo desde borrador o programada. */
export async function sendCampaign(id: string, at?: Date, db: Executor = getDb()): Promise<Campaign> {
const [row] = await db
.update(campaigns)
.set({ status: at ? 'scheduled' : 'sending', scheduledAt: at ?? null })
.where(and(eq(campaigns.id, id), inArray(campaigns.status, ['draft', 'scheduled'])))
.returning();
if (!row) throw new NewsletterError('invalid_state', 'campaign is not a draft');
const dedupeKey = `newsletter.batch:${id}`;
// Si ya estaba programada, la tarea pendiente tiene la misma clave y ganaría con su fecha antigua: se mueve a la nueva
// (ahora, con "Enviar ya").
await db.update(jobs).set({ runAt: at ?? new Date() }).where(and(eq(jobs.dedupeKey, dedupeKey), eq(jobs.status, 'queued')));
await sendBatchJob.enqueue({ campaignId: id }, { runAt: at, dedupeKey }, db);
return row;
}
export async function cancelCampaign(id: string, db: Executor = getDb()): Promise<void> {
await db.update(campaigns).set({ status: 'cancelled' }).where(and(eq(campaigns.id, id), inArray(campaigns.status, ['scheduled', 'sending'])));
}
export const BATCH_SIZE = 100;
/** Una reserva `sending` más vieja que esto es de un lote que murió (el job dura como mucho 10 min): se reintenta. */
export const STALE_CLAIM_MS = 15 * 60_000;
/**
* Lote: prepara destinatarios la primera vez, reserva hasta BATCH_SIZE de forma atómica (dos lotes a la vez nunca toman
* el mismo destinatario), envía y se reencola si quedan. El siguiente lote usa una clave estable (`…:<n>`): si dos
* lotes del mismo turno acaban a la vez, solo se encola uno.
*/
export const sendBatchJob = defineJob(
'newsletter.send-batch',
z.object({ campaignId: z.string(), seq: z.number().int().min(0).optional() }),
async ({ campaignId, seq = 0 }, { signal }) => {
const db = getDb();
const [c] = await db.select().from(campaigns).where(eq(campaigns.id, campaignId));
if (!c || c.status === 'cancelled' || c.status === 'sent' || c.status === 'draft') return;
if (c.status === 'scheduled') await db.update(campaigns).set({ status: 'sending' }).where(and(eq(campaigns.id, campaignId), eq(campaigns.status, 'scheduled')));
// Destinatarios: activos (con alguna de las etiquetas, si hay). INSERT … SELECT idempotente.
const tagFilter = c.tags.length ? sql` and s.tags ?| ${sql.raw(`array[${c.tags.map((t) => `'${t.replace(/[^a-z0-9-]/g, '')}'`).join(',')}]`)}` : sql``;
await db.execute(
sql`insert into newsletter_sends (campaign_id, subscriber_id) select ${campaignId}, s.id from subscribers s where s.status = 'active'${tagFilter} on conflict do nothing`,
);
const stale = new Date(Date.now() - STALE_CLAIM_MS);
const claimed = await db
.update(campaignSends)
.set({ status: 'sending', claimedAt: new Date() })
.where(
and(
eq(campaignSends.campaignId, campaignId),
sql`${campaignSends.subscriberId} in (select subscriber_id from newsletter_sends where campaign_id = ${campaignId} and (status = 'queued' or (status = 'sending' and claimed_at < ${stale.toISOString()}::timestamptz)) limit ${BATCH_SIZE} for update skip locked)`,
),
)
.returning({ subscriberId: campaignSends.subscriberId });
const batch = claimed.length
? await db
.select({ subscriberId: subscribers.id, email: subscribers.email, locale: subscribers.locale, status: subscribers.status })
.from(subscribers)
.where(inArray(subscribers.id, claimed.map((r) => r.subscriberId)))
: [];
const from = env('NEWSLETTER_FROM');
let sent = 0;
let failed = 0;
const done = new Set<string>();
for (const r of batch) {
if (signal.aborted) break;
let status: 'sent' | 'failed' = 'sent';
let error: string | null = null;
if (r.status !== 'active') {
status = 'failed';
error = `subscriber ${r.status}`;
} else {
try {
const m = await renderCampaign(c, { id: r.subscriberId, email: r.email, locale: r.locale });
await getNewsletterSender().send({ from, to: r.email, ...m });
} catch (e) {
status = 'failed';
error = (e instanceof Error ? e.message : String(e)).slice(0, 500);
}
}
status === 'sent' ? sent++ : failed++;
done.add(r.subscriberId);
await db
.update(campaignSends)
.set({ status, error, sentAt: status === 'sent' ? new Date() : null })
.where(and(eq(campaignSends.campaignId, campaignId), eq(campaignSends.subscriberId, r.subscriberId)));
}
// Reservas que no se llegaron a enviar (tarea abortada): vuelven a la cola.
const undone = claimed.map((r) => r.subscriberId).filter((id) => !done.has(id));
if (undone.length)
await db
.update(campaignSends)
.set({ status: 'queued', claimedAt: null })
.where(and(eq(campaignSends.campaignId, campaignId), eq(campaignSends.status, 'sending'), inArray(campaignSends.subscriberId, undone)));
if (sent || failed)
await db
.update(campaigns)
.set({ sentCount: sql`${campaigns.sentCount} + ${sent}`, failedCount: sql`${campaigns.failedCount} + ${failed}` })
.where(eq(campaigns.id, campaignId));
const left = await db
.select({ status: campaignSends.status, n: sql<number>`count(*)`.mapWith(Number) })
.from(campaignSends)
.where(and(eq(campaignSends.campaignId, campaignId), inArray(campaignSends.status, ['queued', 'sending'])))
.groupBy(campaignSends.status);
const queued = left.find((l) => l.status === 'queued')?.n ?? 0;
const sending = left.find((l) => l.status === 'sending')?.n ?? 0;
const next = { campaignId, seq: seq + 1 };
const dedupeKey = `newsletter.batch:${campaignId}:${seq + 1}`;
if (queued > 0) await sendBatchJob.enqueue(next, { dedupeKey });
// Solo quedan reservas de otro lote en curso: él terminará; por si murió, se vuelve a mirar cuando caduquen.
else if (sending > 0) await sendBatchJob.enqueue(next, { dedupeKey, runAt: new Date(Date.now() + STALE_CLAIM_MS) });
// `where status = 'sending'`: no pisa una campaña cancelada mientras tanto.
else await db.update(campaigns).set({ status: 'sent', sentAt: new Date() }).where(and(eq(campaigns.id, campaignId), eq(campaigns.status, 'sending')));
},
{ timeoutMs: 10 * 60_000, maxAttempts: 10 },
);
이 패키지는 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개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?