npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

unified-newsletter-core

v0.1.0

Published

A Drizzle-native newsletter core with subscriptions, consent, durable campaigns, retries, and unsubscribe handling.

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-orm

The package is ESM, requires Node.js 20 or newer, and targets PostgreSQL.

Add the schema

The default schema exports five tables:

  • unlc_lists
  • unlc_subscribers
  • unlc_subscriptions
  • unlc_campaigns
  • unlc_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 idempotencyKey is 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.id to 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.