@ingram-tech/nk-marketing
v0.5.2
Published
Postgres-backed marketing & lifecycle email: contacts, consent, newsletter audiences, and idempotent triggered campaigns with RFC 8058 one-click unsubscribe. Sends via @ingram-tech/nk-email.
Readme
@ingram-tech/nk-marketing
Postgres-backed marketing and lifecycle email: contacts and consent, newsletter
audiences/subscriptions (broadcast), and idempotent triggered campaigns
(post-signup drips). RFC 8058 one-click unsubscribe throughout. Sends via
@ingram-tech/nk-email.
It owns its tables and ships the migration; you inject an @ingram-tech/nk-db
pool (or any pg client) and a base URL. It defines its own row types and never
reaches into your schema.
Install
bun add @ingram-tech/nk-marketing @ingram-tech/nk-email1. Apply the schema
The migration lives in the package. Fold migrations/0001_marketing.sql into
your drizzle/ pipeline (or apply it directly). It creates marketing_contacts,
marketing_audiences, marketing_subscriptions, and marketing_deliveries, with
no RLS: reach them through your app role, as with nk-billing.
2. Wire it up
import { createMarketing } from "@ingram-tech/nk-marketing";
import { pool } from "@/lib/db"; // your shared nk-db pool
const marketing = createMarketing({
db: pool,
baseUrl: "https://example.com",
// unsubscribePath defaults to "/api/marketing/unsubscribe"
});Point one route at marketing.unsubscribe(token) — it resolves either a
per-list subscription token (drops that audience) or a contact token (global
marketing opt-out) and is idempotent.
Newsletters (broadcast)
await marketing.subscribe({ audienceSlug: "product-updates", email });
await marketing.sendBroadcast({
audienceSlug: "product-updates",
subject: "What's new",
content: "First line.\n\nSecond paragraph.",
cta: { label: "Read more", href: "https://example.com/post" },
campaignKey: "2026-06-issue", // optional — makes a re-run idempotent
// onlyTo: ["[email protected]"], // test send
});Each recipient gets a per-subscription unsubscribe link; a globally opted-out contact is excluded automatically.
Lifecycle / triggered campaigns
For "send your first invoice 1 month after signup if they haven't yet" and similar drips. The division of labour:
- Your app owns the trigger. A daily cron queries your schema for due contacts (signed up 30+ days ago, no invoice sent, etc.); that condition depends on your tables, so it stays with you.
- nk-marketing owns the cross-cutting parts. For each due contact you call
sendLifecycle, which suppresses global opt-outs, sends at most once percampaignKeyper contact (claimed before send), attaches one-click unsubscribe, and renders.
// in /internal/cron/onboarding-followup, for each due contact:
const result = await marketing.sendLifecycle({
campaignKey: "first-invoice-nudge",
email: contact.email,
userId: contact.userId,
from: { name: "Acme" },
subject: "Send your first invoice with Acme",
content: "You're all set up — here's how to send your first invoice…",
cta: { label: "Open dashboard", href: "https://example.com/dashboard" },
footerReason: "you have an Acme account",
});
// result.status: "sent" | "duplicate" | "suppressed"sendLifecycle throws only if the underlying send fails, releasing its claim
first so the next run retries. Wrap it best-effort so a mail outage never blocks
your cron.
Send history
Every broadcast and lifecycle send is recorded to nk-email's nk_email_log as
kind: "marketing", which is what an operator surface reads. Logging is
best-effort (a logging failure never blocks a send) and needs nk-email's
0001_email_log.sql. Set logSends: false to opt out entirely.
By default a row is the envelope only: recipient, subject, sender, campaign key. To archive what was actually sent, so the surface can preview the message rather than just prove it left:
const marketing = createMarketing({
db: pool,
baseUrl: "https://example.com",
captureBody: true, // needs nk-email's 0002_email_log_extras.sql
});captureBody is off by default because the body column arrives with
nk-email's second migration; defaulting it on would make every send fail to log
on a site that had applied only the first. Turn it on and stored bodies become
personal data that nothing expires for you, so schedule a purge (recipe in
nk-email's README).
Unlike nk-email, this is one switch for the whole client rather than a per-send override: marketing bodies carry no credential, so there is no equivalent of a magic-link body you need to keep out of the archive.
Rendering
The built-in renderer produces dependency-free HTML + text. Override it with
createMarketing({ render }) to use your own template (e.g. React Email).
Requires @ingram-tech/nk-email's env (CLOUDFLARE_*, EMAIL_FROM_DOMAIN).
