Newsletter: Double-Opt-in, Abmeldung mit einem Klick (RFC 8058), Tags und Kampagnen in fortsetzbaren Stapeln
Installieren
genpm add @core/newsletterWas du bekommst
- Quellcode in src/lib/newsletter/, 11 Dateien. (40,7 kB)
- KI-Regeln in src/lib/newsletter/AGENTS.md, dazu Regeldateien für die IDE.
- Umgebungsvariablen in .env.example ergänzt: NEWSLETTER_FROM, NEWSLETTER_POSTAL_ADDRESS, NEWSLETTER_SECRET, SITE_URL.
- Löst @core/antispam, @core/contracts, @core/db, @core/email, @core/jobs, @core/rich-text für dich auf.
README
Dieses Paket hat keine README.
Genau das liest deine KI, wenn sie in src/lib/newsletter arbeitet. Sonst wird ihrem Kontext nichts hinzugefügt.
@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.
Der genaue Baum, der nach .genpmignore eingebunden wird. Gepinnt an
// 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;
Dieses Paket deklariert keine MCP-Server.
| Version | Commit | Veröffentlicht | Prüfung |
|---|---|---|---|
| 1.1.0 | 48a34fe | vor 6 Stunden | Prüfung bestanden |
- 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
- vorgeschlagen
- GenPM schlägt den npm-Befehl vor und führt ihn nur aus, wenn du zustimmst.
- Verwendet von (1)
- @core/kit-blog ^1.1.0
- Prüfung
- Prüfung bestanden · 0 Befunde
- Commit
- v1.1.0 → 48a34feb692c5ef63dc34278296cb446d534233b · nach dem Abruf verifiziert
- Skripte
- Keine. GenPM führt niemals Paketcode aus.
- Lizenz
- MIT
- Qualität
- 100/100
- Anerkannte Lizenzerfüllt
- AGENTS.md erklärt den Zweckerfüllt
- AGENTS.md enthält Integrationsschritteerfüllt
- AGENTS.md nennt Konventionen oder Verboteerfüllt
- Enthält Testserfüllt
- Sicherheitsscan bestandenerfüllt
- In den letzten 6 Monaten veröffentlichterfüllt
- Verifizierter Herausgebererfüllt
- Zusammenfassung und Schlagwörtererfüllt
- Meldung
- Stimmt etwas nicht?