Newsletter: double opt-in, one-click unsubscribe (RFC 8058), tags, rich-text campaigns sent in resumable batches
Install
genpm add @core/newsletterWhat you get
- Source in src/lib/newsletter/, 11 files. (40.7 kB)
- AI rules in src/lib/newsletter/AGENTS.md, plus IDE rule files.
- Env vars added to .env.example: NEWSLETTER_FROM, NEWSLETTER_POSTAL_ADDRESS, NEWSLETTER_SECRET, SITE_URL.
- Resolves @core/antispam, @core/contracts, @core/db, @core/email, @core/jobs, @core/rich-text for you.
README
This package has no README.
This is exactly what your AI reads when it works in src/lib/newsletter. Nothing else is added to its context.
@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.
The exact tree that will be injected, after .genpmignore. Pinned to
// Rutas públicas: POST /subscribe; GET y POST /confirm?token=; GET y POST /unsubscribe?token=.
// Los GET solo pintan una página con un botón que hace POST: los escáneres de enlaces del correo (que abren cada URL)
// no confirman ni dan de baja a nadie. El POST de un clic de RFC 8058 (`List-Unsubscribe=One-Click`) sigue funcionando.
import { confirmSubscription, NewsletterError, subscribe, unsubscribe } from '../index.ts';
import { readToken, type TokenKind } from '../tokens.ts';
export type NewsletterPages = { confirmed: string; unsubscribed: string; invalid: string };
const DEFAULT_PAGES: NewsletterPages = { confirmed: '/newsletter/confirmed', unsubscribed: '/newsletter/unsubscribed', invalid: '/newsletter/invalid' };
/** Vuelve a la página con `?newsletter=pending|<error>#newsletter` (el formulario muestra el aviso). */
function backWith(back: URL, result: string): Response {
back.searchParams.set('newsletter', result);
back.hash = 'newsletter';
return new Response(null, { status: 303, headers: { location: back.toString() } });
}
/** Textos de las páginas intermedias (traducibles con la opción `texts`). */
export type NewsletterPageTexts = { confirmTitle: string; confirmButton: string; unsubscribeTitle: string; unsubscribeButton: string };
const DEFAULT_TEXTS: NewsletterPageTexts = {
confirmTitle: 'Confirm your subscription',
confirmButton: 'Confirm subscription',
unsubscribeTitle: 'Unsubscribe from the newsletter',
unsubscribeButton: 'Unsubscribe',
};
const escHtml = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
/** Página mínima con un botón que repite la petición como POST (con el mismo token). */
function actionPage(req: Request, kind: TokenKind, texts: Partial<NewsletterPageTexts>): Response {
const t = { ...DEFAULT_TEXTS, ...texts };
const [title, button] = kind === 'confirm' ? [t.confirmTitle, t.confirmButton] : [t.unsubscribeTitle, t.unsubscribeButton];
const action = `?token=${encodeURIComponent(new URL(req.url).searchParams.get('token') ?? '')}`;
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escHtml(title)}</title></head><body><main><h1>${escHtml(title)}</h1><form method="post" action="${escHtml(action)}"><input type="hidden" name="via" value="page"><button type="submit">${escHtml(button)}</button></form></main></body></html>`;
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'referrer-policy': 'no-referrer',
'x-robots-tag': 'noindex',
'content-security-policy': "default-src 'none'; form-action 'self'; frame-ancestors 'none'",
},
});
}
const redirect = (to: string) => new Response(null, { status: 303, headers: { location: to } });
/** Envío HTML clásico (sin JavaScript) desde una página del mismo sitio: URL a la que volver, o null. */
function backUrl(req: Request, isJson: boolean): URL | null {
if (isJson || !(req.headers.get('accept') ?? '').includes('text/html')) return null;
const ref = req.headers.get('referer');
if (!ref) return null;
try {
const u = new URL(ref);
return u.host === new URL(req.url).host ? u : null;
} catch {
return null;
}
}
export async function handleSubscribe(req: Request, opts: { tags?: string[]; source?: string } = {}): Promise<Response> {
const isJson = (req.headers.get('content-type') ?? '').includes('application/json');
const fields: Record<string, unknown> = {};
if (isJson) Object.assign(fields, await req.json().catch(() => ({})));
else (await req.formData()).forEach((v, k) => typeof v === 'string' && (fields[k] = v));
try {
// Un formulario que declara `consentVersion` (el texto de su casilla) solo vale con la casilla marcada.
const consentVersion = typeof fields.consentVersion === 'string' ? fields.consentVersion : undefined;
if (consentVersion && !['on', 'true', true].includes(fields.consent as string | boolean)) throw new NewsletterError('consent_required');
await subscribe(
{ email: String(fields.email ?? ''), tags: opts.tags, source: String(fields.source ?? opts.source ?? '') || undefined, locale: typeof fields.locale === 'string' ? fields.locale : undefined, consentVersion },
{ headers: req.headers, fields },
);
// Misma respuesta para altas nuevas y existentes.
const back = backUrl(req, isJson);
if (back) return backWith(back, 'pending');
return Response.json({ ok: true }, { status: 202 });
} catch (e) {
if (e instanceof NewsletterError) {
const back = backUrl(req, isJson);
return back ? backWith(back, e.code) : Response.json({ error: e.code }, { status: e.code === 'rate_limited' ? 429 : 400 });
}
throw e;
}
}
/** GET: página con el botón (si el token es válido). POST: confirma y redirige a `pages.confirmed`. */
export async function handleConfirm(req: Request, pages: Partial<NewsletterPages> = {}, texts: Partial<NewsletterPageTexts> = {}): Promise<Response> {
const p = { ...DEFAULT_PAGES, ...pages };
const token = new URL(req.url).searchParams.get('token');
if (req.method !== 'POST') return (await readToken(token, 'confirm')) ? actionPage(req, 'confirm', texts) : redirect(p.invalid);
try {
await confirmSubscription(token);
return redirect(p.confirmed);
} catch (e) {
if (e instanceof NewsletterError) return redirect(p.invalid);
throw e;
}
}
/**
* GET: página con el botón (si el token es válido). POST desde esa página: baja y redirige a `pages.unsubscribed`.
* Cualquier otro POST es la baja en un clic de RFC 8058 (desde el cliente de correo): 200/400 sin redirigir.
*/
export async function handleUnsubscribe(req: Request, pages: Partial<NewsletterPages> = {}, texts: Partial<NewsletterPageTexts> = {}): Promise<Response> {
const p = { ...DEFAULT_PAGES, ...pages };
const token = new URL(req.url).searchParams.get('token');
if (req.method !== 'POST') return (await readToken(token, 'unsubscribe')) ? actionPage(req, 'unsubscribe', texts) : redirect(p.invalid);
const form = (req.headers.get('content-type') ?? '').includes('application/x-www-form-urlencoded') ? await req.formData().catch(() => null) : null;
const fromPage = form?.get('via') === 'page';
try {
await unsubscribe(token);
return fromPage ? redirect(p.unsubscribed) : new Response(null, { status: 200 });
} catch (e) {
if (e instanceof NewsletterError) return fromPage ? redirect(p.invalid) : new Response(null, { status: 400 });
throw e;
}
}
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.1.0 | 48a34fe | 6 hours ago | scan passed |
- 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
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- Used by (1)
- @core/kit-blog ^1.1.0
- scan
- scan passed · 0 findings
- commit
- v1.1.0 → 48a34feb692c5ef63dc34278296cb446d534233b · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- Quality
- 100/100
- Recognized licensepassed
- AGENTS.md explains its purposepassed
- AGENTS.md has integration stepspassed
- AGENTS.md lists conventions or don'tspassed
- Includes testspassed
- Security scan passedpassed
- Released in the last 6 monthspassed
- Verified publisherpassed
- Summary and keywordspassed
- report
- See something wrong?