@ingram-tech/nk-marketing 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/client.d.ts +39 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +266 -0
- package/dist/client.js.map +1 -0
- package/dist/db.d.ts +20 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +16 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/render.d.ts +31 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +40 -0
- package/dist/render.js.map +1 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/migrations/0001_marketing.sql +87 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @ingram-tech/nk-marketing
|
|
2
|
+
|
|
3
|
+
Postgres-backed **marketing & lifecycle email**: contacts and consent,
|
|
4
|
+
newsletter audiences/subscriptions (broadcast), and **idempotent triggered
|
|
5
|
+
campaigns** (post-signup drips). RFC 8058 one-click unsubscribe throughout.
|
|
6
|
+
Sends via [`@ingram-tech/nk-email`](../nk-email).
|
|
7
|
+
|
|
8
|
+
This is the Postgres-native successor to `@ingram-tech/newsletter` (which was
|
|
9
|
+
Supabase-bound). It **owns its tables** and ships the migration; you inject an
|
|
10
|
+
`@ingram-tech/nk-db` pool (or any `pg` client) and a base URL. It defines its own
|
|
11
|
+
row types and never reaches into your schema.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @ingram-tech/nk-marketing @ingram-tech/nk-email
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 1. Apply the schema
|
|
20
|
+
|
|
21
|
+
The migration lives in the package. Fold `migrations/0001_marketing.sql` into
|
|
22
|
+
your `drizzle/` pipeline (or apply it directly). It creates `marketing_contacts`,
|
|
23
|
+
`marketing_audiences`, `marketing_subscriptions`, and `marketing_deliveries` —
|
|
24
|
+
no RLS (reach them through your app role, like nk-billing).
|
|
25
|
+
|
|
26
|
+
## 2. Wire it up
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createMarketing } from "@ingram-tech/nk-marketing";
|
|
30
|
+
import { pool } from "@/lib/db"; // your shared nk-db pool
|
|
31
|
+
|
|
32
|
+
const marketing = createMarketing({
|
|
33
|
+
db: pool,
|
|
34
|
+
baseUrl: "https://example.com",
|
|
35
|
+
// unsubscribePath defaults to "/api/marketing/unsubscribe"
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Point one route at `marketing.unsubscribe(token)` — it resolves either a
|
|
40
|
+
per-list subscription token (drops that audience) or a contact token (global
|
|
41
|
+
marketing opt-out) and is idempotent.
|
|
42
|
+
|
|
43
|
+
## Newsletters (broadcast)
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await marketing.subscribe({ audienceSlug: "product-updates", email });
|
|
47
|
+
|
|
48
|
+
await marketing.sendBroadcast({
|
|
49
|
+
audienceSlug: "product-updates",
|
|
50
|
+
subject: "What's new",
|
|
51
|
+
content: "First line.\n\nSecond paragraph.",
|
|
52
|
+
cta: { label: "Read more", href: "https://example.com/post" },
|
|
53
|
+
campaignKey: "2026-06-issue", // optional — makes a re-run idempotent
|
|
54
|
+
// onlyTo: ["you@example.com"], // test send
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Each recipient gets a per-subscription unsubscribe link; a globally opted-out
|
|
59
|
+
contact is excluded automatically.
|
|
60
|
+
|
|
61
|
+
## Lifecycle / triggered campaigns
|
|
62
|
+
|
|
63
|
+
For "send your first invoice 1 month after signup if they haven't yet" and
|
|
64
|
+
similar drips. **The division of labour:**
|
|
65
|
+
|
|
66
|
+
- **Your app owns the trigger.** A daily cron queries *your* schema for due
|
|
67
|
+
contacts (signed up 30+ days ago, no invoice sent, etc.) — that condition
|
|
68
|
+
depends on your tables, so it stays with you.
|
|
69
|
+
- **nk-marketing owns the cross-cutting parts.** For each due contact you call
|
|
70
|
+
`sendLifecycle`, which suppresses global opt-outs, sends **at most once** per
|
|
71
|
+
`campaignKey` per contact (claimed before send), attaches one-click
|
|
72
|
+
unsubscribe, and renders.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// in /internal/cron/onboarding-followup, for each due contact:
|
|
76
|
+
const result = await marketing.sendLifecycle({
|
|
77
|
+
campaignKey: "first-invoice-nudge",
|
|
78
|
+
email: contact.email,
|
|
79
|
+
userId: contact.userId,
|
|
80
|
+
from: { name: "Peppost" },
|
|
81
|
+
subject: "Send your first Stripe e-invoice with Peppost",
|
|
82
|
+
content: "You're all set up — here's how to send your first invoice…",
|
|
83
|
+
cta: { label: "Open dashboard", href: "https://example.com/dashboard" },
|
|
84
|
+
footerReason: "you have a Peppost account",
|
|
85
|
+
});
|
|
86
|
+
// result.status: "sent" | "duplicate" | "suppressed"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`sendLifecycle` throws only if the underlying send fails (releasing its claim
|
|
90
|
+
first so the next run retries) — wrap it best-effort so a mail outage never
|
|
91
|
+
blocks your cron.
|
|
92
|
+
|
|
93
|
+
## Rendering
|
|
94
|
+
|
|
95
|
+
The built-in renderer produces clean, dependency-free HTML + text. Override it
|
|
96
|
+
with `createMarketing({ render })` to use your own template (e.g. React Email).
|
|
97
|
+
|
|
98
|
+
Requires `@ingram-tech/nk-email`'s env (`CLOUDFLARE_*`, `EMAIL_FROM_DOMAIN`).
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marketing client: contacts + consent, newsletter audiences/subscriptions
|
|
3
|
+
* (broadcast), and idempotent triggered campaigns (lifecycle). Follows
|
|
4
|
+
* nextkit's Django-app model — the package owns its tables (migrations/) and
|
|
5
|
+
* takes the database by injection; the consuming site owns tenancy, scheduling,
|
|
6
|
+
* and the conditions that decide *who* gets a lifecycle email *when*.
|
|
7
|
+
*
|
|
8
|
+
* The division of labour for lifecycle email (the "send your first invoice"
|
|
9
|
+
* nudge): the SITE runs a daily cron and queries its own schema to find due
|
|
10
|
+
* contacts (e.g. signed up 30+ days ago, never sent an invoice). For each, it
|
|
11
|
+
* calls {@link sendLifecycle}, which owns the cross-cutting parts — suppression
|
|
12
|
+
* (global opt-out), exactly-once delivery (claim before send), one-click
|
|
13
|
+
* unsubscribe, and rendering. The site never re-implements those.
|
|
14
|
+
*/
|
|
15
|
+
import { type Queryable } from "./db.js";
|
|
16
|
+
import { type MarketingRenderInput } from "./render.js";
|
|
17
|
+
import type { Audience, BroadcastOptions, BroadcastResult, Contact, IdentifyOptions, LifecycleOptions, LifecycleResult, SubscribeOptions, Subscription, UnsubscribeResult } from "./types.js";
|
|
18
|
+
export interface MarketingConfig {
|
|
19
|
+
/** A `pg` Pool/PoolClient or nk-db query interface (see {@link Queryable}). */
|
|
20
|
+
db: Queryable;
|
|
21
|
+
/** Absolute base URL for unsubscribe links, e.g. "https://example.com". */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/** Unsubscribe route path. Default "/api/marketing/unsubscribe". */
|
|
24
|
+
unsubscribePath?: string;
|
|
25
|
+
/** Override the built-in HTML/text rendering. */
|
|
26
|
+
render?: (input: MarketingRenderInput) => {
|
|
27
|
+
html: string;
|
|
28
|
+
text: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare const createMarketing: (config: MarketingConfig) => {
|
|
32
|
+
identify: (options: IdentifyOptions) => Promise<Contact>;
|
|
33
|
+
getAudienceBySlug: (slug: string) => Promise<Audience | null>;
|
|
34
|
+
subscribe: (options: SubscribeOptions) => Promise<Subscription>;
|
|
35
|
+
unsubscribe: (token: string) => Promise<UnsubscribeResult>;
|
|
36
|
+
sendBroadcast: (options: BroadcastOptions) => Promise<BroadcastResult>;
|
|
37
|
+
sendLifecycle: (options: LifecycleOptions) => Promise<LifecycleResult>;
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAiC,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AACxE,OAAO,EAEN,KAAK,oBAAoB,EAGzB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACX,QAAQ,EACR,gBAAgB,EAChB,eAAe,EACf,OAAO,EACP,eAAe,EACf,gBAAgB,EAChB,eAAe,EAEf,gBAAgB,EAChB,YAAY,EACZ,iBAAiB,EACjB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,eAAe;IAC/B,+EAA+E;IAC/E,EAAE,EAAE,SAAS,CAAC;IACd,2EAA2E;IAC3E,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iDAAiD;IACjD,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE;AAKD,eAAO,MAAM,eAAe,GAAI,QAAQ,eAAe;wBAiBrB,eAAe,KAAG,OAAO,CAAC,OAAO,CAAC;8BAiB5B,MAAM,KAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;yBAoBtC,gBAAgB,KAAG,OAAO,CAAC,YAAY,CAAC;yBAgCxC,MAAM,KAAG,OAAO,CAAC,iBAAiB,CAAC;6BA8F3D,gBAAgB,KACvB,OAAO,CAAC,eAAe,CAAC;6BAqFjB,gBAAgB,KACvB,OAAO,CAAC,eAAe,CAAC;CA8C3B,CAAC"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marketing client: contacts + consent, newsletter audiences/subscriptions
|
|
3
|
+
* (broadcast), and idempotent triggered campaigns (lifecycle). Follows
|
|
4
|
+
* nextkit's Django-app model — the package owns its tables (migrations/) and
|
|
5
|
+
* takes the database by injection; the consuming site owns tenancy, scheduling,
|
|
6
|
+
* and the conditions that decide *who* gets a lifecycle email *when*.
|
|
7
|
+
*
|
|
8
|
+
* The division of labour for lifecycle email (the "send your first invoice"
|
|
9
|
+
* nudge): the SITE runs a daily cron and queries its own schema to find due
|
|
10
|
+
* contacts (e.g. signed up 30+ days ago, never sent an invoice). For each, it
|
|
11
|
+
* calls {@link sendLifecycle}, which owns the cross-cutting parts — suppression
|
|
12
|
+
* (global opt-out), exactly-once delivery (claim before send), one-click
|
|
13
|
+
* unsubscribe, and rendering. The site never re-implements those.
|
|
14
|
+
*/
|
|
15
|
+
import { fromAddress, sendEmail } from "@ingram-tech/nk-email";
|
|
16
|
+
import { generateToken, normalizeEmail } from "./db.js";
|
|
17
|
+
import { derivePreviewText, renderMarketingHtml, renderMarketingText, } from "./render.js";
|
|
18
|
+
/** The bare address inside a "Name <addr>" string (or the string itself). */
|
|
19
|
+
const bareAddress = (from) => from.match(/<([^>]+)>/)?.[1] ?? from;
|
|
20
|
+
export const createMarketing = (config) => {
|
|
21
|
+
const { db, baseUrl } = config;
|
|
22
|
+
const unsubscribePath = config.unsubscribePath ?? "/api/marketing/unsubscribe";
|
|
23
|
+
const unsubscribeUrl = (token) => `${baseUrl}${unsubscribePath}?token=${encodeURIComponent(token)}`;
|
|
24
|
+
const renderFn = config.render ??
|
|
25
|
+
((input) => ({
|
|
26
|
+
html: renderMarketingHtml(input),
|
|
27
|
+
text: renderMarketingText(input),
|
|
28
|
+
}));
|
|
29
|
+
/**
|
|
30
|
+
* Upsert a contact by email. New email → insert (with a fresh unsubscribe
|
|
31
|
+
* token); existing → backfill user_id/locale without overwriting consent or
|
|
32
|
+
* the token. The unit every other call resolves to.
|
|
33
|
+
*/
|
|
34
|
+
const identify = async (options) => {
|
|
35
|
+
const email = normalizeEmail(options.email);
|
|
36
|
+
const { rows } = await db.query(`insert into marketing_contacts (email, user_id, locale, unsubscribe_token)
|
|
37
|
+
values ($1, $2, $3, $4)
|
|
38
|
+
on conflict (email) do update set
|
|
39
|
+
user_id = coalesce(marketing_contacts.user_id, excluded.user_id),
|
|
40
|
+
locale = coalesce(excluded.locale, marketing_contacts.locale),
|
|
41
|
+
updated_at = now()
|
|
42
|
+
returning id, email, user_id, locale, unsubscribe_token, unsubscribed_all_at`, [email, options.userId ?? null, options.locale ?? null, generateToken()]);
|
|
43
|
+
const contact = rows[0];
|
|
44
|
+
if (!contact)
|
|
45
|
+
throw new Error("nk-marketing: failed to upsert contact");
|
|
46
|
+
return contact;
|
|
47
|
+
};
|
|
48
|
+
const getAudienceBySlug = async (slug) => {
|
|
49
|
+
const { rows } = await db.query(`select id, slug, name, from_name, from_local_part, reply_to, is_active
|
|
50
|
+
from marketing_audiences where slug = $1`, [slug]);
|
|
51
|
+
return rows[0] ?? null;
|
|
52
|
+
};
|
|
53
|
+
const requireAudience = async (slug) => {
|
|
54
|
+
const audience = await getAudienceBySlug(slug);
|
|
55
|
+
if (!audience)
|
|
56
|
+
throw new Error(`nk-marketing: unknown audience "${slug}"`);
|
|
57
|
+
return audience;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Idempotent subscribe to a broadcast audience: new → insert; existing →
|
|
61
|
+
* resurrect (clear unsubscribed_at) and backfill source. Identifies the
|
|
62
|
+
* contact first so consent and identity share one row.
|
|
63
|
+
*/
|
|
64
|
+
const subscribe = async (options) => {
|
|
65
|
+
const audience = await requireAudience(options.audienceSlug);
|
|
66
|
+
if (!audience.is_active) {
|
|
67
|
+
throw new Error(`nk-marketing: audience is not active "${options.audienceSlug}"`);
|
|
68
|
+
}
|
|
69
|
+
const contact = await identify({
|
|
70
|
+
email: options.email,
|
|
71
|
+
userId: options.userId,
|
|
72
|
+
});
|
|
73
|
+
const { rows } = await db.query(`insert into marketing_subscriptions (audience_id, contact_id, unsubscribe_token, source)
|
|
74
|
+
values ($1, $2, $3, $4)
|
|
75
|
+
on conflict (audience_id, contact_id) do update set
|
|
76
|
+
unsubscribed_at = null,
|
|
77
|
+
source = coalesce(excluded.source, marketing_subscriptions.source),
|
|
78
|
+
updated_at = now()
|
|
79
|
+
returning id, audience_id, contact_id, unsubscribe_token, unsubscribed_at, source`, [audience.id, contact.id, generateToken(), options.source ?? null]);
|
|
80
|
+
const subscription = rows[0];
|
|
81
|
+
if (!subscription)
|
|
82
|
+
throw new Error("nk-marketing: failed to subscribe");
|
|
83
|
+
return subscription;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Resolve an unsubscribe token and act on it. A subscription token drops just
|
|
87
|
+
* that audience; a contact token is a global marketing opt-out (suppresses
|
|
88
|
+
* everything). Idempotent — a second click reports "already". Point your
|
|
89
|
+
* unsubscribe route straight at this.
|
|
90
|
+
*/
|
|
91
|
+
const unsubscribe = async (token) => {
|
|
92
|
+
const sub = await db.query(`select id, unsubscribed_at from marketing_subscriptions where unsubscribe_token = $1`, [token]);
|
|
93
|
+
const subRow = sub.rows[0];
|
|
94
|
+
if (subRow) {
|
|
95
|
+
if (subRow.unsubscribed_at)
|
|
96
|
+
return { status: "already" };
|
|
97
|
+
await db.query(`update marketing_subscriptions set unsubscribed_at = now(), updated_at = now()
|
|
98
|
+
where id = $1`, [subRow.id]);
|
|
99
|
+
return { status: "unsubscribed", scope: "audience" };
|
|
100
|
+
}
|
|
101
|
+
const contact = await db.query(`select id, unsubscribed_all_at from marketing_contacts where unsubscribe_token = $1`, [token]);
|
|
102
|
+
const contactRow = contact.rows[0];
|
|
103
|
+
if (contactRow) {
|
|
104
|
+
if (contactRow.unsubscribed_all_at)
|
|
105
|
+
return { status: "already" };
|
|
106
|
+
await db.query(`update marketing_contacts set unsubscribed_all_at = now(), updated_at = now()
|
|
107
|
+
where id = $1`, [contactRow.id]);
|
|
108
|
+
return { status: "unsubscribed", scope: "global" };
|
|
109
|
+
}
|
|
110
|
+
return { status: "unknown" };
|
|
111
|
+
};
|
|
112
|
+
/** Claim (campaign_key, contact) before a send. Returns false if already taken. */
|
|
113
|
+
const claimDelivery = async (campaignKey, contactId) => {
|
|
114
|
+
const { rows } = await db.query(`insert into marketing_deliveries (campaign_key, contact_id) values ($1, $2)
|
|
115
|
+
on conflict (campaign_key, contact_id) do nothing returning contact_id`, [campaignKey, contactId]);
|
|
116
|
+
return rows.length > 0;
|
|
117
|
+
};
|
|
118
|
+
/** Release a claim so a failed send can be retried on the next run. */
|
|
119
|
+
const releaseDelivery = async (campaignKey, contactId) => {
|
|
120
|
+
await db.query(`delete from marketing_deliveries where campaign_key = $1 and contact_id = $2`, [campaignKey, contactId]);
|
|
121
|
+
};
|
|
122
|
+
const send = async (args) => {
|
|
123
|
+
const { html, text } = renderFn(args.input);
|
|
124
|
+
await sendEmail({
|
|
125
|
+
to: args.to,
|
|
126
|
+
from: args.from,
|
|
127
|
+
replyTo: args.replyTo,
|
|
128
|
+
subject: args.subject,
|
|
129
|
+
html,
|
|
130
|
+
text,
|
|
131
|
+
listUnsubscribe: {
|
|
132
|
+
url: args.input.unsubscribeUrl,
|
|
133
|
+
mailto: bareAddress(args.from),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Send a broadcast to every active subscriber of an audience (a global
|
|
139
|
+
* opt-out excludes them too). Each recipient gets a per-subscription
|
|
140
|
+
* unsubscribe link. Failures are caught per-recipient so one bad address
|
|
141
|
+
* can't poison the batch. Pass `campaignKey` to make a re-run idempotent
|
|
142
|
+
* (recipients already delivered under that key are skipped).
|
|
143
|
+
*
|
|
144
|
+
* Sends are sequential with no built-in rate limiting — fine for the lists we
|
|
145
|
+
* run today; revisit (batching/backoff) before large sends.
|
|
146
|
+
*/
|
|
147
|
+
const sendBroadcast = async (options) => {
|
|
148
|
+
const audience = await requireAudience(options.audienceSlug);
|
|
149
|
+
const { rows: recipients } = await db.query(`select s.unsubscribe_token as sub_token, c.id as contact_id, c.email as email
|
|
150
|
+
from marketing_subscriptions s
|
|
151
|
+
join marketing_contacts c on c.id = s.contact_id
|
|
152
|
+
where s.audience_id = $1
|
|
153
|
+
and s.unsubscribed_at is null
|
|
154
|
+
and c.unsubscribed_all_at is null
|
|
155
|
+
order by s.subscribed_at asc`, [audience.id]);
|
|
156
|
+
const targets = options.onlyTo
|
|
157
|
+
? (() => {
|
|
158
|
+
const allowed = new Set(options.onlyTo.map(normalizeEmail));
|
|
159
|
+
return recipients.filter((r) => allowed.has(r.email));
|
|
160
|
+
})()
|
|
161
|
+
: recipients;
|
|
162
|
+
const from = fromAddress(audience.from_name, audience.from_local_part);
|
|
163
|
+
const previewText = options.previewText ?? derivePreviewText(options.content);
|
|
164
|
+
const result = {
|
|
165
|
+
totalRecipients: targets.length,
|
|
166
|
+
sentCount: 0,
|
|
167
|
+
skippedCount: 0,
|
|
168
|
+
failedCount: 0,
|
|
169
|
+
failures: [],
|
|
170
|
+
};
|
|
171
|
+
for (const r of targets) {
|
|
172
|
+
if (options.campaignKey) {
|
|
173
|
+
const claimed = await claimDelivery(options.campaignKey, r.contact_id);
|
|
174
|
+
if (!claimed) {
|
|
175
|
+
result.skippedCount += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await send({
|
|
181
|
+
to: r.email,
|
|
182
|
+
from,
|
|
183
|
+
replyTo: audience.reply_to ?? undefined,
|
|
184
|
+
subject: options.subject,
|
|
185
|
+
input: {
|
|
186
|
+
subject: options.subject,
|
|
187
|
+
content: options.content,
|
|
188
|
+
cta: options.cta,
|
|
189
|
+
unsubscribeUrl: unsubscribeUrl(r.sub_token),
|
|
190
|
+
previewText,
|
|
191
|
+
footerReason: `you subscribed to ${audience.name}`,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
result.sentCount += 1;
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (options.campaignKey) {
|
|
198
|
+
await releaseDelivery(options.campaignKey, r.contact_id);
|
|
199
|
+
}
|
|
200
|
+
result.failedCount += 1;
|
|
201
|
+
result.failures.push({
|
|
202
|
+
email: r.email,
|
|
203
|
+
error: err instanceof Error ? err.message : String(err),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Send one lifecycle/triggered email to one contact, at most once per
|
|
211
|
+
* `campaignKey`. Upserts the contact, then: skips if globally opted out
|
|
212
|
+
* ({@link LifecycleResult} "suppressed"); claims the delivery and skips if
|
|
213
|
+
* already sent ("duplicate"); otherwise sends with a global one-click
|
|
214
|
+
* unsubscribe link and returns "sent". The claim is released if the send
|
|
215
|
+
* throws, so the caller's next run retries. The *scheduling and eligibility*
|
|
216
|
+
* (when, and to whom) are the caller's job — see this module's header.
|
|
217
|
+
*
|
|
218
|
+
* @throws if the underlying send fails (after releasing the claim). Callers
|
|
219
|
+
* should wrap best-effort so a mail outage never blocks their cron.
|
|
220
|
+
*/
|
|
221
|
+
const sendLifecycle = async (options) => {
|
|
222
|
+
const contact = await identify({
|
|
223
|
+
email: options.email,
|
|
224
|
+
userId: options.userId,
|
|
225
|
+
locale: options.locale,
|
|
226
|
+
});
|
|
227
|
+
if (contact.unsubscribed_all_at)
|
|
228
|
+
return { status: "suppressed" };
|
|
229
|
+
const claimed = await claimDelivery(options.campaignKey, contact.id);
|
|
230
|
+
if (!claimed)
|
|
231
|
+
return { status: "duplicate" };
|
|
232
|
+
const sender = options.from;
|
|
233
|
+
const from = fromAddress(sender.name, sender.localPart);
|
|
234
|
+
try {
|
|
235
|
+
await send({
|
|
236
|
+
to: contact.email,
|
|
237
|
+
from,
|
|
238
|
+
replyTo: sender.replyTo,
|
|
239
|
+
subject: options.subject,
|
|
240
|
+
input: {
|
|
241
|
+
subject: options.subject,
|
|
242
|
+
content: options.content,
|
|
243
|
+
cta: options.cta,
|
|
244
|
+
unsubscribeUrl: unsubscribeUrl(contact.unsubscribe_token),
|
|
245
|
+
previewText: options.previewText ?? derivePreviewText(options.content),
|
|
246
|
+
footerReason: options.footerReason ??
|
|
247
|
+
`you have an account with ${sender.name}`,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
await releaseDelivery(options.campaignKey, contact.id);
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
return { status: "sent" };
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
identify,
|
|
259
|
+
getAudienceBySlug,
|
|
260
|
+
subscribe,
|
|
261
|
+
unsubscribe,
|
|
262
|
+
sendBroadcast,
|
|
263
|
+
sendLifecycle,
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,cAAc,EAAkB,MAAM,SAAS,CAAC;AACxE,OAAO,EACN,iBAAiB,EAEjB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,aAAa,CAAC;AA0BrB,6EAA6E;AAC7E,MAAM,WAAW,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AAEnF,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,MAAuB,EAAE,EAAE;IAC1D,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,MAAM,CAAC;IAC/B,MAAM,eAAe,GAAG,MAAM,CAAC,eAAe,IAAI,4BAA4B,CAAC;IAC/E,MAAM,cAAc,GAAG,CAAC,KAAa,EAAE,EAAE,CACxC,GAAG,OAAO,GAAG,eAAe,UAAU,kBAAkB,CAAC,KAAK,CAAC,EAAE,CAAC;IACnE,MAAM,QAAQ,GACb,MAAM,CAAC,MAAM;QACb,CAAC,CAAC,KAA2B,EAAE,EAAE,CAAC,CAAC;YAClC,IAAI,EAAE,mBAAmB,CAAC,KAAK,CAAC;YAChC,IAAI,EAAE,mBAAmB,CAAC,KAAK,CAAC;SAChC,CAAC,CAAC,CAAC;IAEL;;;;OAIG;IACH,MAAM,QAAQ,GAAG,KAAK,EAAE,OAAwB,EAAoB,EAAE;QACrE,MAAM,KAAK,GAAG,cAAc,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC5C,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC9B;;;;;;iFAM8E,EAC9E,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI,EAAE,aAAa,EAAE,CAAC,CACxE,CAAC;QACF,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;QACxE,OAAO,OAAO,CAAC;IAChB,CAAC,CAAC;IAEF,MAAM,iBAAiB,GAAG,KAAK,EAAE,IAAY,EAA4B,EAAE;QAC1E,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC9B;6CAC0C,EAC1C,CAAC,IAAI,CAAC,CACN,CAAC;QACF,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IACxB,CAAC,CAAC;IAEF,MAAM,eAAe,GAAG,KAAK,EAAE,IAAY,EAAqB,EAAE;QACjE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAC/C,IAAI,CAAC,QAAQ;YAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,IAAI,GAAG,CAAC,CAAC;QAC3E,OAAO,QAAQ,CAAC;IACjB,CAAC,CAAC;IAEF;;;;OAIG;IACH,MAAM,SAAS,GAAG,KAAK,EAAE,OAAyB,EAAyB,EAAE;QAC5E,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QAC7D,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CACd,yCAAyC,OAAO,CAAC,YAAY,GAAG,CAChE,CAAC;QACH,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC;YAC9B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,MAAM,EAAE,OAAO,CAAC,MAAM;SACtB,CAAC,CAAC;QACH,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC9B;;;;;;sFAMmF,EACnF,CAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,aAAa,EAAE,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI,CAAC,CAClE,CAAC;QACF,MAAM,YAAY,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QAC7B,IAAI,CAAC,YAAY;YAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;QACxE,OAAO,YAAY,CAAC;IACrB,CAAC,CAAC;IAEF;;;;;OAKG;IACH,MAAM,WAAW,GAAG,KAAK,EAAE,KAAa,EAA8B,EAAE;QACvE,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,KAAK,CACzB,sFAAsF,EACtF,CAAC,KAAK,CAAC,CACP,CAAC;QACF,MAAM,MAAM,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC3B,IAAI,MAAM,EAAE,CAAC;YACZ,IAAI,MAAM,CAAC,eAAe;gBAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;YACzD,MAAM,EAAE,CAAC,KAAK,CACb;mBACe,EACf,CAAC,MAAM,CAAC,EAAE,CAAC,CACX,CAAC;YACF,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC;QACtD,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,KAAK,CAI7B,qFAAqF,EACrF,CAAC,KAAK,CAAC,CACP,CAAC;QACF,MAAM,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACnC,IAAI,UAAU,EAAE,CAAC;YAChB,IAAI,UAAU,CAAC,mBAAmB;gBAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;YACjE,MAAM,EAAE,CAAC,KAAK,CACb;mBACe,EACf,CAAC,UAAU,CAAC,EAAE,CAAC,CACf,CAAC;YACF,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;QACpD,CAAC;QAED,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC9B,CAAC,CAAC;IAEF,mFAAmF;IACnF,MAAM,aAAa,GAAG,KAAK,EAC1B,WAAmB,EACnB,SAAiB,EACE,EAAE;QACrB,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC9B;2EACwE,EACxE,CAAC,WAAW,EAAE,SAAS,CAAC,CACxB,CAAC;QACF,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;IACxB,CAAC,CAAC;IAEF,uEAAuE;IACvE,MAAM,eAAe,GAAG,KAAK,EAC5B,WAAmB,EACnB,SAAiB,EACD,EAAE;QAClB,MAAM,EAAE,CAAC,KAAK,CACb,8EAA8E,EAC9E,CAAC,WAAW,EAAE,SAAS,CAAC,CACxB,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,IAAI,GAAG,KAAK,EAAE,IAMnB,EAAiB,EAAE;QACnB,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC5C,MAAM,SAAS,CAAC;YACf,EAAE,EAAE,IAAI,CAAC,EAAE;YACX,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,IAAI;YACJ,IAAI;YACJ,eAAe,EAAE;gBAChB,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,cAAc;gBAC9B,MAAM,EAAE,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC;aAC9B;SACD,CAAC,CAAC;IACJ,CAAC,CAAC;IAEF;;;;;;;;;OASG;IACH,MAAM,aAAa,GAAG,KAAK,EAC1B,OAAyB,EACE,EAAE;QAC7B,MAAM,QAAQ,GAAG,MAAM,eAAe,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QAC7D,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAK1C;;;;;;iCAM8B,EAC9B,CAAC,QAAQ,CAAC,EAAE,CAAC,CACb,CAAC;QAEF,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM;YAC7B,CAAC,CAAC,CAAC,GAAG,EAAE;gBACN,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC;gBAC5D,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;YACvD,CAAC,CAAC,EAAE;YACL,CAAC,CAAC,UAAU,CAAC;QAEd,MAAM,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,eAAe,CAAC,CAAC;QACvE,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,iBAAiB,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC9E,MAAM,MAAM,GAAoB;YAC/B,eAAe,EAAE,OAAO,CAAC,MAAM;YAC/B,SAAS,EAAE,CAAC;YACZ,YAAY,EAAE,CAAC;YACf,WAAW,EAAE,CAAC;YACd,QAAQ,EAAE,EAAE;SACZ,CAAC;QAEF,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;YACzB,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;gBACzB,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC;gBACvE,IAAI,CAAC,OAAO,EAAE,CAAC;oBACd,MAAM,CAAC,YAAY,IAAI,CAAC,CAAC;oBACzB,SAAS;gBACV,CAAC;YACF,CAAC;YACD,IAAI,CAAC;gBACJ,MAAM,IAAI,CAAC;oBACV,EAAE,EAAE,CAAC,CAAC,KAAK;oBACX,IAAI;oBACJ,OAAO,EAAE,QAAQ,CAAC,QAAQ,IAAI,SAAS;oBACvC,OAAO,EAAE,OAAO,CAAC,OAAO;oBACxB,KAAK,EAAE;wBACN,OAAO,EAAE,OAAO,CAAC,OAAO;wBACxB,OAAO,EAAE,OAAO,CAAC,OAAO;wBACxB,GAAG,EAAE,OAAO,CAAC,GAAG;wBAChB,cAAc,EAAE,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC;wBAC3C,WAAW;wBACX,YAAY,EAAE,qBAAqB,QAAQ,CAAC,IAAI,EAAE;qBAClD;iBACD,CAAC,CAAC;gBACH,MAAM,CAAC,SAAS,IAAI,CAAC,CAAC;YACvB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACd,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;oBACzB,MAAM,eAAe,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC;gBAC1D,CAAC;gBACD,MAAM,CAAC,WAAW,IAAI,CAAC,CAAC;gBACxB,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;oBACpB,KAAK,EAAE,CAAC,CAAC,KAAK;oBACd,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;iBACvD,CAAC,CAAC;YACJ,CAAC;QACF,CAAC;QACD,OAAO,MAAM,CAAC;IACf,CAAC,CAAC;IAEF;;;;;;;;;;;OAWG;IACH,MAAM,aAAa,GAAG,KAAK,EAC1B,OAAyB,EACE,EAAE;QAC7B,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC;YAC9B,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,MAAM,EAAE,OAAO,CAAC,MAAM;SACtB,CAAC,CAAC;QACH,IAAI,OAAO,CAAC,mBAAmB;YAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC;QAEjE,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;QACrE,IAAI,CAAC,OAAO;YAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;QAE7C,MAAM,MAAM,GAAW,OAAO,CAAC,IAAI,CAAC;QACpC,MAAM,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC;QACxD,IAAI,CAAC;YACJ,MAAM,IAAI,CAAC;gBACV,EAAE,EAAE,OAAO,CAAC,KAAK;gBACjB,IAAI;gBACJ,OAAO,EAAE,MAAM,CAAC,OAAO;gBACvB,OAAO,EAAE,OAAO,CAAC,OAAO;gBACxB,KAAK,EAAE;oBACN,OAAO,EAAE,OAAO,CAAC,OAAO;oBACxB,OAAO,EAAE,OAAO,CAAC,OAAO;oBACxB,GAAG,EAAE,OAAO,CAAC,GAAG;oBAChB,cAAc,EAAE,cAAc,CAAC,OAAO,CAAC,iBAAiB,CAAC;oBACzD,WAAW,EACV,OAAO,CAAC,WAAW,IAAI,iBAAiB,CAAC,OAAO,CAAC,OAAO,CAAC;oBAC1D,YAAY,EACX,OAAO,CAAC,YAAY;wBACpB,4BAA4B,MAAM,CAAC,IAAI,EAAE;iBAC1C;aACD,CAAC,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,MAAM,eAAe,CAAC,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;YACvD,MAAM,GAAG,CAAC;QACX,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAC3B,CAAC,CAAC;IAEF,OAAO;QACN,QAAQ;QACR,iBAAiB;QACjB,SAAS;QACT,WAAW;QACX,aAAa;QACb,aAAa;KACb,CAAC;AACH,CAAC,CAAC"}
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The database surface nk-marketing needs, by injection — identical in spirit to
|
|
3
|
+
* `@ingram-tech/nk-billing`'s `Queryable`. A structural `pg` Pool/PoolClient and
|
|
4
|
+
* nk-db's query helpers both satisfy it, so the consuming site passes whatever
|
|
5
|
+
* it already has and owns the tenancy/transaction story.
|
|
6
|
+
*/
|
|
7
|
+
export interface Queryable {
|
|
8
|
+
query<R = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<{
|
|
9
|
+
rows: R[];
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
/** Normalise an email for storage and lookup — trimmed + lowercased. */
|
|
13
|
+
export declare const normalizeEmail: (email: string) => string;
|
|
14
|
+
/**
|
|
15
|
+
* A 256-bit random token, hex-encoded, for an unsubscribe link. Generated in
|
|
16
|
+
* app code (not via a pgcrypto column default) so the migration stays
|
|
17
|
+
* extension-free and the value never leaks a row id.
|
|
18
|
+
*/
|
|
19
|
+
export declare const generateToken: () => string;
|
|
20
|
+
//# sourceMappingURL=db.d.ts.map
|
package/dist/db.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,MAAM,WAAW,SAAS;IAGzB,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,GAChB,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,CAAC,CAAC;CAC1B;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,MAAoC,CAAC;AAEpF;;;;GAIG;AACH,eAAO,MAAM,aAAa,QAAO,MAAyC,CAAC"}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The database surface nk-marketing needs, by injection — identical in spirit to
|
|
3
|
+
* `@ingram-tech/nk-billing`'s `Queryable`. A structural `pg` Pool/PoolClient and
|
|
4
|
+
* nk-db's query helpers both satisfy it, so the consuming site passes whatever
|
|
5
|
+
* it already has and owns the tenancy/transaction story.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
/** Normalise an email for storage and lookup — trimmed + lowercased. */
|
|
9
|
+
export const normalizeEmail = (email) => email.trim().toLowerCase();
|
|
10
|
+
/**
|
|
11
|
+
* A 256-bit random token, hex-encoded, for an unsubscribe link. Generated in
|
|
12
|
+
* app code (not via a pgcrypto column default) so the migration stays
|
|
13
|
+
* extension-free and the value never leaks a row id.
|
|
14
|
+
*/
|
|
15
|
+
export const generateToken = () => randomBytes(32).toString("hex");
|
|
16
|
+
//# sourceMappingURL=db.js.map
|
package/dist/db.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAW1C,wEAAwE;AACxE,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AAEpF;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,GAAW,EAAE,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createMarketing, type MarketingConfig } from "./client.js";
|
|
2
|
+
export { type Queryable } from "./db.js";
|
|
3
|
+
export { derivePreviewText, type MarketingRenderInput, renderMarketingHtml, renderMarketingText, } from "./render.js";
|
|
4
|
+
export type { Audience, BroadcastOptions, BroadcastResult, Contact, Cta, IdentifyOptions, LifecycleOptions, LifecycleResult, Sender, SubscribeOptions, Subscription, UnsubscribeResult, } from "./types.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,EACN,iBAAiB,EACjB,KAAK,oBAAoB,EACzB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,aAAa,CAAC;AACrB,YAAY,EACX,QAAQ,EACR,gBAAgB,EAChB,eAAe,EACf,OAAO,EACP,GAAG,EACH,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,MAAM,EACN,gBAAgB,EAChB,YAAY,EACZ,iBAAiB,GACjB,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAwB,MAAM,aAAa,CAAC;AAEpE,OAAO,EACN,iBAAiB,EAEjB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,aAAa,CAAC"}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default, dependency-free HTML + text renderer for marketing email. Shared
|
|
3
|
+
* by broadcasts and lifecycle sends; override per `createMarketing({ render })`
|
|
4
|
+
* to drop in your own template (e.g. React Email). HTML escaping comes from
|
|
5
|
+
* `@ingram-tech/nk-email` so there is exactly one copy of it.
|
|
6
|
+
*/
|
|
7
|
+
import type { Cta } from "./types.js";
|
|
8
|
+
/** Inputs the default renderer (or a custom one) receives per recipient. */
|
|
9
|
+
export interface MarketingRenderInput {
|
|
10
|
+
subject: string;
|
|
11
|
+
/** Plain-text body; blank lines split paragraphs. */
|
|
12
|
+
content: string;
|
|
13
|
+
cta?: Cta | null;
|
|
14
|
+
/** Absolute one-click unsubscribe URL for this recipient. */
|
|
15
|
+
unsubscribeUrl: string;
|
|
16
|
+
/** Inbox-preview headline. */
|
|
17
|
+
previewText?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Why this person is receiving the email, completing "You're receiving this
|
|
20
|
+
* because …" — e.g. "you subscribed to Acme Updates" or "you have an Acme
|
|
21
|
+
* account".
|
|
22
|
+
*/
|
|
23
|
+
footerReason: string;
|
|
24
|
+
}
|
|
25
|
+
/** Minimal, dependency-free HTML email. */
|
|
26
|
+
export declare const renderMarketingHtml: (input: MarketingRenderInput) => string;
|
|
27
|
+
/** Plain-text counterpart of {@link renderMarketingHtml}. */
|
|
28
|
+
export declare const renderMarketingText: (input: MarketingRenderInput) => string;
|
|
29
|
+
/** First non-empty line, trimmed to ~140 chars — a sensible preview default. */
|
|
30
|
+
export declare const derivePreviewText: (content: string) => string;
|
|
31
|
+
//# sourceMappingURL=render.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEtC,4EAA4E;AAC5E,MAAM,WAAW,oBAAoB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,GAAG,GAAG,IAAI,CAAC;IACjB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAC;IACvB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,2CAA2C;AAC3C,eAAO,MAAM,mBAAmB,GAAI,OAAO,oBAAoB,KAAG,MAuBjE,CAAC;AAEF,6DAA6D;AAC7D,eAAO,MAAM,mBAAmB,GAAI,OAAO,oBAAoB,KAAG,MAGgC,CAAC;AAEnG,gFAAgF;AAChF,eAAO,MAAM,iBAAiB,GAAI,SAAS,MAAM,KAAG,MAOnD,CAAC"}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default, dependency-free HTML + text renderer for marketing email. Shared
|
|
3
|
+
* by broadcasts and lifecycle sends; override per `createMarketing({ render })`
|
|
4
|
+
* to drop in your own template (e.g. React Email). HTML escaping comes from
|
|
5
|
+
* `@ingram-tech/nk-email` so there is exactly one copy of it.
|
|
6
|
+
*/
|
|
7
|
+
import { escapeHtml } from "@ingram-tech/nk-email";
|
|
8
|
+
/** Minimal, dependency-free HTML email. */
|
|
9
|
+
export const renderMarketingHtml = (input) => {
|
|
10
|
+
const paragraphs = input.content
|
|
11
|
+
.split(/\n\s*\n/)
|
|
12
|
+
.map((p) => p.trim())
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map((p) => `<p style="margin:0 0 16px;line-height:1.6;">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
|
|
15
|
+
.join("\n");
|
|
16
|
+
const cta = input.cta
|
|
17
|
+
? `<p style="margin:24px 0;"><a href="${escapeHtml(input.cta.href)}" style="background:#111;color:#fff;padding:12px 20px;border-radius:8px;text-decoration:none;display:inline-block;">${escapeHtml(input.cta.label)}</a></p>`
|
|
18
|
+
: "";
|
|
19
|
+
return `<!doctype html><html><body style="font-family:Arial,Helvetica,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#111;">
|
|
20
|
+
<div style="display:none;max-height:0;overflow:hidden;">${escapeHtml(input.previewText ?? "")}</div>
|
|
21
|
+
<h1 style="font-size:20px;margin:0 0 16px;">${escapeHtml(input.subject)}</h1>
|
|
22
|
+
${paragraphs}
|
|
23
|
+
${cta}
|
|
24
|
+
<hr style="border:none;border-top:1px solid #e5e5e5;margin:32px 0 16px;"/>
|
|
25
|
+
<p style="font-size:12px;color:#888;">You're receiving this because ${escapeHtml(input.footerReason)}. <a href="${escapeHtml(input.unsubscribeUrl)}" style="color:#888;">Unsubscribe</a>.</p>
|
|
26
|
+
</body></html>`;
|
|
27
|
+
};
|
|
28
|
+
/** Plain-text counterpart of {@link renderMarketingHtml}. */
|
|
29
|
+
export const renderMarketingText = (input) => `${input.subject}\n\n${input.content}\n\n${input.cta ? `${input.cta.label}: ${input.cta.href}\n\n` : ""}---\nYou're receiving this because ${input.footerReason}.\nUnsubscribe: ${input.unsubscribeUrl}`;
|
|
30
|
+
/** First non-empty line, trimmed to ~140 chars — a sensible preview default. */
|
|
31
|
+
export const derivePreviewText = (content) => {
|
|
32
|
+
const first = content
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((line) => line.trim())
|
|
35
|
+
.find((line) => line.length > 0);
|
|
36
|
+
if (!first)
|
|
37
|
+
return "";
|
|
38
|
+
return first.length > 140 ? `${first.slice(0, 137)}…` : first;
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAqBnD,2CAA2C;AAC3C,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,KAA2B,EAAU,EAAE;IAC1E,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO;SAC9B,KAAK,CAAC,SAAS,CAAC;SAChB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CACH,CAAC,CAAC,EAAE,EAAE,CACL,+CAA+C,UAAU,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,CAC3F;SACA,IAAI,CAAC,IAAI,CAAC,CAAC;IAEb,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG;QACpB,CAAC,CAAC,sCAAsC,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,uHAAuH,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,UAAU;QAC9N,CAAC,CAAC,EAAE,CAAC;IAEN,OAAO;0DACkD,UAAU,CAAC,KAAK,CAAC,WAAW,IAAI,EAAE,CAAC;8CAC/C,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC;EACrE,UAAU;EACV,GAAG;;sEAEiE,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC,cAAc,UAAU,CAAC,KAAK,CAAC,cAAc,CAAC;eACnI,CAAC;AAChB,CAAC,CAAC;AAEF,6DAA6D;AAC7D,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,KAA2B,EAAU,EAAE,CAC1E,GAAG,KAAK,CAAC,OAAO,OAAO,KAAK,CAAC,OAAO,OACnC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK,KAAK,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,EAC3D,sCAAsC,KAAK,CAAC,YAAY,mBAAmB,KAAK,CAAC,cAAc,EAAE,CAAC;AAEnG,gFAAgF;AAChF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,OAAe,EAAU,EAAE;IAC5D,MAAM,KAAK,GAAG,OAAO;SACnB,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAClC,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAC;IACtB,OAAO,KAAK,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;AAC/D,CAAC,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row interfaces for the package's own tables (see migrations/0001_marketing.sql)
|
|
3
|
+
* and the public option/result shapes. Following the nk-billing precedent, these
|
|
4
|
+
* are plain typed interfaces read via `db.query<Row>(...)` rather than Zod
|
|
5
|
+
* schemas: the rows come back from a typed `pg`/nk-db query, not from untyped
|
|
6
|
+
* Supabase JSON, so the boundary that the "validate with Zod" rule guards isn't
|
|
7
|
+
* crossed here.
|
|
8
|
+
*
|
|
9
|
+
* Timestamps are typed as `string` — a nextkit site configures `pg` to return
|
|
10
|
+
* timestamptz as ISO strings (nk-db's `configureTimestampsAsStrings`). The
|
|
11
|
+
* client only ever reads them for null-ness, so the exact representation does
|
|
12
|
+
* not matter to this package.
|
|
13
|
+
*/
|
|
14
|
+
/** Row of `marketing_contacts`. */
|
|
15
|
+
export interface Contact {
|
|
16
|
+
id: string;
|
|
17
|
+
email: string;
|
|
18
|
+
user_id: string | null;
|
|
19
|
+
locale: string | null;
|
|
20
|
+
unsubscribe_token: string;
|
|
21
|
+
unsubscribed_all_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** Row of `marketing_audiences`. */
|
|
24
|
+
export interface Audience {
|
|
25
|
+
id: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
name: string;
|
|
28
|
+
from_name: string;
|
|
29
|
+
from_local_part: string;
|
|
30
|
+
reply_to: string | null;
|
|
31
|
+
is_active: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** Row of `marketing_subscriptions`. */
|
|
34
|
+
export interface Subscription {
|
|
35
|
+
id: string;
|
|
36
|
+
audience_id: string;
|
|
37
|
+
contact_id: string;
|
|
38
|
+
unsubscribe_token: string;
|
|
39
|
+
unsubscribed_at: string | null;
|
|
40
|
+
source: string | null;
|
|
41
|
+
}
|
|
42
|
+
/** A call-to-action button rendered in the email body. */
|
|
43
|
+
export interface Cta {
|
|
44
|
+
label: string;
|
|
45
|
+
href: string;
|
|
46
|
+
}
|
|
47
|
+
/** Sender identity for a lifecycle send (broadcasts take it from the audience). */
|
|
48
|
+
export interface Sender {
|
|
49
|
+
/** Display name, e.g. "Peppost". */
|
|
50
|
+
name: string;
|
|
51
|
+
/** Local part of the from address; defaults to "notifications". */
|
|
52
|
+
localPart?: string;
|
|
53
|
+
replyTo?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface IdentifyOptions {
|
|
56
|
+
email: string;
|
|
57
|
+
userId?: string;
|
|
58
|
+
locale?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface SubscribeOptions {
|
|
61
|
+
audienceSlug: string;
|
|
62
|
+
email: string;
|
|
63
|
+
source?: string;
|
|
64
|
+
userId?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface BroadcastOptions {
|
|
67
|
+
audienceSlug: string;
|
|
68
|
+
subject: string;
|
|
69
|
+
/** Plain-text body; blank lines split paragraphs in the default renderer. */
|
|
70
|
+
content: string;
|
|
71
|
+
previewText?: string;
|
|
72
|
+
cta?: Cta;
|
|
73
|
+
/** If set, only send to these addresses (case-insensitive). For test sends. */
|
|
74
|
+
onlyTo?: string[];
|
|
75
|
+
/**
|
|
76
|
+
* If set, each recipient is claimed in `marketing_deliveries` under this key
|
|
77
|
+
* before sending, making a re-run idempotent (the issue id is a good value).
|
|
78
|
+
* Omit to send unconditionally to every active subscriber (legacy behaviour).
|
|
79
|
+
*/
|
|
80
|
+
campaignKey?: string;
|
|
81
|
+
}
|
|
82
|
+
export interface LifecycleOptions {
|
|
83
|
+
/** Stable program/step key, e.g. "first-invoice-nudge". Dedupes per contact. */
|
|
84
|
+
campaignKey: string;
|
|
85
|
+
email: string;
|
|
86
|
+
userId?: string;
|
|
87
|
+
locale?: string;
|
|
88
|
+
subject: string;
|
|
89
|
+
content: string;
|
|
90
|
+
previewText?: string;
|
|
91
|
+
cta?: Cta;
|
|
92
|
+
from: Sender;
|
|
93
|
+
/**
|
|
94
|
+
* Footer line explaining why the contact received this, e.g. "you have a
|
|
95
|
+
* Peppost account". Defaults to a generic account-based reason.
|
|
96
|
+
*/
|
|
97
|
+
footerReason?: string;
|
|
98
|
+
}
|
|
99
|
+
export interface BroadcastResult {
|
|
100
|
+
totalRecipients: number;
|
|
101
|
+
sentCount: number;
|
|
102
|
+
/** Recipients skipped because already delivered under `campaignKey`. */
|
|
103
|
+
skippedCount: number;
|
|
104
|
+
failedCount: number;
|
|
105
|
+
failures: {
|
|
106
|
+
email: string;
|
|
107
|
+
error: string;
|
|
108
|
+
}[];
|
|
109
|
+
}
|
|
110
|
+
export type LifecycleResult = {
|
|
111
|
+
status: "sent";
|
|
112
|
+
}
|
|
113
|
+
/** Contact has globally opted out of marketing. */
|
|
114
|
+
| {
|
|
115
|
+
status: "suppressed";
|
|
116
|
+
}
|
|
117
|
+
/** Already delivered to this contact under this campaignKey. */
|
|
118
|
+
| {
|
|
119
|
+
status: "duplicate";
|
|
120
|
+
};
|
|
121
|
+
export type UnsubscribeResult = {
|
|
122
|
+
status: "unsubscribed";
|
|
123
|
+
scope: "audience" | "global";
|
|
124
|
+
}
|
|
125
|
+
/** Token matched but was already unsubscribed. */
|
|
126
|
+
| {
|
|
127
|
+
status: "already";
|
|
128
|
+
}
|
|
129
|
+
/** No subscription or contact carries this token. */
|
|
130
|
+
| {
|
|
131
|
+
status: "unknown";
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row interfaces for the package's own tables (see migrations/0001_marketing.sql)
|
|
3
|
+
* and the public option/result shapes. Following the nk-billing precedent, these
|
|
4
|
+
* are plain typed interfaces read via `db.query<Row>(...)` rather than Zod
|
|
5
|
+
* schemas: the rows come back from a typed `pg`/nk-db query, not from untyped
|
|
6
|
+
* Supabase JSON, so the boundary that the "validate with Zod" rule guards isn't
|
|
7
|
+
* crossed here.
|
|
8
|
+
*
|
|
9
|
+
* Timestamps are typed as `string` — a nextkit site configures `pg` to return
|
|
10
|
+
* timestamptz as ISO strings (nk-db's `configureTimestampsAsStrings`). The
|
|
11
|
+
* client only ever reads them for null-ness, so the exact representation does
|
|
12
|
+
* not matter to this package.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
-- @ingram-tech/nk-marketing — contacts, audiences, subscriptions, deliveries.
|
|
2
|
+
--
|
|
3
|
+
-- The stateful slice powering newsletter broadcasts AND post-signup lifecycle
|
|
4
|
+
-- email. This is the Postgres-native successor to @ingram-tech/newsletter
|
|
5
|
+
-- (which was Supabase/RLS-bound). Like @ingram-tech/nk-billing's ledger, these
|
|
6
|
+
-- tables carry NO row-level security: a nextkit site reaches them through its
|
|
7
|
+
-- app role and filters in the app layer. Add your own RLS only if your stack
|
|
8
|
+
-- needs it.
|
|
9
|
+
--
|
|
10
|
+
-- Ship this as a Drizzle schema fragment + generated SQL so it composes with the
|
|
11
|
+
-- consuming site's drizzle-kit pipeline (see docs/marketing.md). Table names are
|
|
12
|
+
-- hardcoded by the client; adjust only on a collision.
|
|
13
|
+
--
|
|
14
|
+
-- updated_at is maintained in app code (the client sets `updated_at = now()` on
|
|
15
|
+
-- every UPDATE) rather than by a trigger, to keep this migration plpgsql-free
|
|
16
|
+
-- and identical across Postgres and PGlite.
|
|
17
|
+
|
|
18
|
+
-- One contact per email address per site. The unit of identity and consent.
|
|
19
|
+
create table if not exists marketing_contacts (
|
|
20
|
+
id uuid primary key default gen_random_uuid(),
|
|
21
|
+
-- Lowercased email is the natural key.
|
|
22
|
+
email text not null unique,
|
|
23
|
+
-- Optional link to the site's own user/account id; lets you target by user.
|
|
24
|
+
user_id text,
|
|
25
|
+
-- BCP-47 locale for localized sends; null = site default.
|
|
26
|
+
locale text,
|
|
27
|
+
-- 256-bit random token behind the GLOBAL one-click unsubscribe link.
|
|
28
|
+
-- Generated in app code (no pgcrypto dependency); never the row id.
|
|
29
|
+
unsubscribe_token text not null unique,
|
|
30
|
+
-- Global marketing opt-out. NULL = opted in. Set by a contact-token
|
|
31
|
+
-- unsubscribe; suppresses BOTH lifecycle sends and broadcasts.
|
|
32
|
+
unsubscribed_all_at timestamptz,
|
|
33
|
+
created_at timestamptz not null default now(),
|
|
34
|
+
updated_at timestamptz not null default now(),
|
|
35
|
+
constraint marketing_contacts_email_lower check (email = lower(email)),
|
|
36
|
+
constraint marketing_contacts_email_format check (email ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- Registry of broadcast lists (newsletters). The sending address is
|
|
40
|
+
-- "<from_local_part>@<EMAIL_FROM_DOMAIN>". Lifecycle campaigns do NOT need an
|
|
41
|
+
-- audience row — they pass their sender inline and target contacts directly.
|
|
42
|
+
create table if not exists marketing_audiences (
|
|
43
|
+
id uuid primary key default gen_random_uuid(),
|
|
44
|
+
slug text not null unique,
|
|
45
|
+
name text not null,
|
|
46
|
+
from_name text not null,
|
|
47
|
+
from_local_part text not null default 'news',
|
|
48
|
+
reply_to text,
|
|
49
|
+
is_active boolean not null default true,
|
|
50
|
+
created_at timestamptz not null default now(),
|
|
51
|
+
updated_at timestamptz not null default now(),
|
|
52
|
+
constraint marketing_audiences_slug_format check (slug ~ '^[a-z0-9][a-z0-9-]*$'),
|
|
53
|
+
constraint marketing_audiences_from_local_part_format check (from_local_part ~ '^[a-z0-9][a-z0-9._-]*$')
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- A contact's opt-in to one audience. One row per (audience, contact); kept on
|
|
57
|
+
-- unsubscribe so a re-subscribe is detectable.
|
|
58
|
+
create table if not exists marketing_subscriptions (
|
|
59
|
+
id uuid primary key default gen_random_uuid(),
|
|
60
|
+
audience_id uuid not null references marketing_audiences (id) on delete cascade,
|
|
61
|
+
contact_id uuid not null references marketing_contacts (id) on delete cascade,
|
|
62
|
+
-- Per-subscription token: a broadcast unsubscribe link drops just THIS list,
|
|
63
|
+
-- distinct from the contact's global token.
|
|
64
|
+
unsubscribe_token text not null unique,
|
|
65
|
+
subscribed_at timestamptz not null default now(),
|
|
66
|
+
unsubscribed_at timestamptz,
|
|
67
|
+
source text,
|
|
68
|
+
created_at timestamptz not null default now(),
|
|
69
|
+
updated_at timestamptz not null default now(),
|
|
70
|
+
constraint marketing_subscriptions_audience_contact_key unique (audience_id, contact_id)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
create index if not exists idx_marketing_subscriptions_audience on marketing_subscriptions (audience_id);
|
|
74
|
+
create index if not exists idx_marketing_subscriptions_contact on marketing_subscriptions (contact_id);
|
|
75
|
+
-- Hot path: enumerating active subscribers for a broadcast.
|
|
76
|
+
create index if not exists idx_marketing_subscriptions_active on marketing_subscriptions (audience_id) where unsubscribed_at is null;
|
|
77
|
+
|
|
78
|
+
-- Idempotency log. One row per (campaign_key, contact), claimed BEFORE a send so
|
|
79
|
+
-- a retry or an overlapping cron can never deliver the same campaign twice. For
|
|
80
|
+
-- lifecycle: campaign_key is the program/step key ("first-invoice-nudge"). For
|
|
81
|
+
-- broadcasts: pass the issue id as campaign_key to make a re-run idempotent.
|
|
82
|
+
create table if not exists marketing_deliveries (
|
|
83
|
+
campaign_key text not null,
|
|
84
|
+
contact_id uuid not null references marketing_contacts (id) on delete cascade,
|
|
85
|
+
sent_at timestamptz not null default now(),
|
|
86
|
+
primary key (campaign_key, contact_id)
|
|
87
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ingram-tech/nk-marketing",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "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.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ingram-technologies/nextkit.git",
|
|
10
|
+
"directory": "packages/nk-marketing"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"migrations"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./migrations/*": "./migrations/*"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"type-check": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@ingram-tech/nk-email": "^0.3.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@ingram-tech/nk-dev": "0.2.3",
|
|
36
|
+
"@types/node": "^25.0.0",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"vitest": "^4.1.6"
|
|
39
|
+
}
|
|
40
|
+
}
|