unified-newsletter-core
v0.1.0
Published
A Drizzle-native newsletter core with subscriptions, consent, durable campaigns, retries, and unsubscribe handling.
Maintainers
Readme
Unified Newsletter Core
unified-newsletter-core is a reusable PostgreSQL/Drizzle newsletter engine.
It gives an application durable campaign delivery without turning the
newsletter system into a microservice or coupling it to an email provider or
template editor.
The package owns:
- lists, subscribers, and per-list subscriptions;
- pending or direct opt-in, consent metadata, and signed confirmation tokens;
- per-list unsubscribe plus global suppression for bounces and complaints;
- draft, scheduled, queued, sending, sent, partial, failed, and canceled campaigns;
- immutable recipient snapshots at queue time;
- durable per-recipient delivery state, retries, stale-lock recovery, and idempotent campaigns;
- ACID queue claiming with PostgreSQL
FOR UPDATE SKIP LOCKED; - signed unsubscribe tokens and RFC 8058 one-click unsubscribe headers.
The host application owns:
- its Drizzle database connection and migrations;
- template storage and authoring (Maily, React Email, MJML, or anything else);
- variable validation and rendering policy;
- sender accounts, provider SDKs, credentials, and
sendMail(); - the public confirmation/unsubscribe routes and URL shape;
- HTTP APIs, admin UI, timers, cron, and worker lifecycle.
Install
npm install unified-newsletter-core drizzle-ormThe package is ESM, requires Node.js 20 or newer, and targets PostgreSQL.
Add the schema
The default schema exports five tables:
unlc_listsunlc_subscribersunlc_subscriptionsunlc_campaignsunlc_deliveries
Re-export them from the consumer's Drizzle schema:
export * from "unified-newsletter-core/schema";Or scan the package schema directly from drizzle.config.ts:
export default defineConfig({
dialect: "postgresql",
schema: [
"./src/db/schema.ts",
"./node_modules/unified-newsletter-core/dist/schema.js",
],
});Applications that need different table names can create and pass one schema instance:
import { createNewsletterSchema } from "unified-newsletter-core/schema";
export const newsletterSchema = createNewsletterSchema({
tablePrefix: "product_news_",
});Use the exact same schema object in createNewsletterCore({ schema }).
Configure the core
import { createNewsletterCore } from "unified-newsletter-core";
import { db } from "./db.js";
import { renderTemplate } from "./templates.js";
import { sendViaPostfix } from "./mail.js";
export const newsletter = createNewsletterCore({
db,
tokenSecret: process.env.NEWSLETTER_TOKEN_SECRET!,
buildUnsubscribeUrl: ({ token }) =>
`https://example.com/newsletter/unsubscribe/${token}`,
renderEmail: async ({ campaign, subscriber, unsubscribeUrl }) => {
const rendered = await renderTemplate(campaign.templateKey, {
...campaign.data,
...subscriber.data,
email: subscriber.email,
unsubscribeUrl,
});
return {
from: "Example <[email protected]>",
subject: rendered.subject,
html: rendered.html,
text: rendered.text,
};
},
sendMail: async ({ delivery, message }) => {
const result = await sendViaPostfix({
...message,
idempotencyKey: delivery.id,
});
return { providerMessageId: result.messageId };
},
});tokenSecret must contain at least 32 bytes. Keep it stable and secret; changing
it invalidates existing confirmation and unsubscribe links.
The core always sets List-Unsubscribe and List-Unsubscribe-Post. The host's
renderer must also place the supplied unsubscribeUrl in visible localized
email content.
Subscribe and confirm
const list = await newsletter.createList({
key: "product-updates",
name: "Product updates",
});
const subscription = await newsletter.subscribe({
listId: list.id,
email: "[email protected]",
status: "pending",
source: "footer",
consent: { form: "homepage", policyVersion: "2026-08-01" },
subscriberData: { firstName: "Ada" },
subscriptionData: { locale: "en" },
});
// Send subscription.confirmationToken with the host's transactional mail path.
await newsletter.confirmSubscription(subscription.confirmationToken!);Use status: "subscribed" for a legitimate direct opt-in or trusted import.
Calling subscribe() again updates the existing list membership rather than
creating duplicates. Suppressed subscribers remain suppressed until the host
explicitly calls unsuppressSubscriber().
An unsubscribe endpoint is deliberately tiny:
const changed = await newsletter.unsubscribe(request.params.token);The token unsubscribes only that list membership. Use
suppressSubscriber({ email, reason }) for a global bounce, complaint, or
administrative block.
Create and queue a campaign
const campaign = await newsletter.createCampaign({
listId: list.id,
name: "August product update",
templateKey: "product-update",
data: { issue: "2026-08" },
idempotencyKey: "product-update:2026-08",
scheduledFor: new Date("2026-08-10T08:00:00Z"),
maxAttempts: 5,
});
await newsletter.queueCampaign(campaign.campaignId);Queueing is an ACID operation. It snapshots all currently active, subscribed recipients into durable delivery rows and can safely be called again. Pending confirmations, unsubscribed memberships, and globally suppressed subscribers are excluded.
The snapshot makes campaign inputs reproducible. Immediately before each send,
the core still checks current subscription and suppression state; a recipient
who opted out after queueing is marked skipped.
Run delivery from the host
let running = false;
setInterval(async () => {
if (running) return;
running = true;
try {
await newsletter.dispatchDue({
workerId: `api-${process.pid}`,
limit: 100,
concurrency: 10,
});
} finally {
running = false;
}
}, 15_000);The package never starts this timer. Multiple application workers may call
dispatchDue() safely; PostgreSQL row locks prevent them from claiming the
same row concurrently. Rendering and provider callbacks run outside the claim
transaction.
Throw NewsletterDeliveryError to classify a provider failure:
throw new NewsletterDeliveryError("address_rejected", "Mailbox rejected", {
retryable: false,
});Other thrown errors are retryable. The default delays are 1 minute, 5 minutes,
15 minutes, 1 hour, and 6 hours. requeueDelivery() supports deliberate manual
recovery.
Delivery semantics
- Campaign creation is idempotent when
idempotencyKeyis supplied. - Queueing and claiming are transactional in the consumer's PostgreSQL database.
- Provider calls cannot be part of a database transaction. Delivery is therefore at least once across a process crash after provider acceptance but before the success row is committed.
- Pass
delivery.idto providers that support an idempotency key. - Cancellation stops unclaimed work. A callback already executing cannot be recalled.
- Templates and rendered bodies are intentionally not stored by the package. Store provider/audit copies in the host if required.
See architecture and adoption notes for the complete boundary.
