@ingram-tech/newsletter 0.1.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 ADDED
@@ -0,0 +1,72 @@
1
+ # @ingram-tech/newsletter
2
+
3
+ Supabase-backed newsletter subscriptions and sending, with idempotent
4
+ subscribe/resubscribe, token-based unsubscribe, and RFC 8058 one-click
5
+ unsubscribe. Ported from fabrile's battle-tested implementation; sends via
6
+ [`@ingram-tech/email`](../email).
7
+
8
+ This package **owns its tables** and ships the migrations; you inject a Supabase
9
+ client and a base URL. It defines its own row types (it does not import your
10
+ generated `Database`), so it drops into any Supabase project.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ bun add @ingram-tech/newsletter @supabase/supabase-js
16
+ ```
17
+
18
+ ## 1. Apply the schema
19
+
20
+ Copy the migrations into your `supabase/migrations` (they live in the package):
21
+
22
+ ```bash
23
+ cp node_modules/@ingram-tech/newsletter/migrations/0001_newsletters.sql \
24
+ supabase/migrations/$(date +%Y%m%d%H%M%S)_newsletters.sql
25
+ ```
26
+
27
+ `0001_newsletters.sql` creates `newsletters` + `newsletter_subscriptions` (UUID
28
+ keys, RLS, indexes). Apply `0002_newsletters_auth_link.sql` **only** if your site
29
+ has user signups and you want pre-signup subscriptions back-linked to users.
30
+
31
+ Then seed a newsletter row (slug, name, from_name, from_local_part).
32
+
33
+ ## 2. Use it
34
+
35
+ ```ts
36
+ import { createNewsletter } from "@ingram-tech/newsletter";
37
+ import { createClient } from "@supabase/supabase-js";
38
+
39
+ const newsletter = createNewsletter({
40
+ supabase: createClient(url, serviceRoleKey), // service-role: writes bypass RLS
41
+ baseUrl: "https://example.com",
42
+ });
43
+
44
+ // Subscribe (idempotent) — e.g. in POST /api/newsletter/subscribe
45
+ await newsletter.subscribe({ newsletterSlug: "product-updates", email });
46
+
47
+ // Unsubscribe — e.g. in your /api/newsletter/unsubscribe route
48
+ await newsletter.unsubscribe(token);
49
+
50
+ // Send to all active subscribers (per-recipient one-click unsubscribe)
51
+ const result = await newsletter.send({
52
+ newsletterSlug: "product-updates",
53
+ subject: "What's new",
54
+ content: "First line.\n\nSecond paragraph.",
55
+ cta: { label: "Read more", href: "https://example.com/post" },
56
+ // onlyTo: ["you@example.com"], // for a test send
57
+ });
58
+ ```
59
+
60
+ Requires `@ingram-tech/email`'s env (`CLOUDFLARE_*`, `EMAIL_FROM_DOMAIN`). The
61
+ sending address is `<from_local_part>@<EMAIL_FROM_DOMAIN>`.
62
+
63
+ ## Rendering
64
+
65
+ The built-in renderer produces a clean, dependency-free HTML + text email.
66
+ Override it with `createNewsletter({ render })` to use your own template
67
+ (e.g. React Email).
68
+
69
+ ## Not included (by design)
70
+
71
+ Open/click tracking and "view in browser" are a separate concern (a future
72
+ `email-tracking` package) — this package focuses on subscriptions + delivery.
@@ -0,0 +1,58 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { type NewsletterRenderInput } from "./render";
3
+ import type { Newsletter, Subscription } from "./types";
4
+ export interface NewsletterConfig {
5
+ /** A Supabase client with service-role access (writes bypass RLS). */
6
+ supabase: SupabaseClient;
7
+ /** Absolute base URL for the unsubscribe link, e.g. "https://example.com". */
8
+ baseUrl: string;
9
+ /** Unsubscribe route path. Default "/api/newsletter/unsubscribe". */
10
+ unsubscribePath?: string;
11
+ /** Override the built-in HTML/text rendering. */
12
+ render?: (input: NewsletterRenderInput) => {
13
+ html: string;
14
+ text: string;
15
+ };
16
+ }
17
+ export interface SubscribeOptions {
18
+ newsletterSlug: string;
19
+ email: string;
20
+ source?: string;
21
+ userId?: string;
22
+ }
23
+ export interface SendOptions {
24
+ newsletterSlug: string;
25
+ subject: string;
26
+ /** Plain-text body; blank lines split paragraphs in the default renderer. */
27
+ content: string;
28
+ previewText?: string;
29
+ cta?: {
30
+ label: string;
31
+ href: string;
32
+ };
33
+ /** If set, only send to these addresses (case-insensitive). For test sends. */
34
+ onlyTo?: string[];
35
+ }
36
+ export interface SendResult {
37
+ newsletter: Newsletter;
38
+ totalRecipients: number;
39
+ sentCount: number;
40
+ failedCount: number;
41
+ failures: {
42
+ email: string;
43
+ error: string;
44
+ }[];
45
+ }
46
+ /**
47
+ * Build a newsletter client bound to a Supabase project. The package owns its
48
+ * tables (see migrations/) and defines its own row types; you inject the client
49
+ * and base URL. Ported from the battle-tested fabrile implementation.
50
+ */
51
+ export declare const createNewsletter: (config: NewsletterConfig) => {
52
+ getBySlug: (slug: string) => Promise<Newsletter | null>;
53
+ subscribe: (options: SubscribeOptions) => Promise<Subscription>;
54
+ unsubscribe: (token: string) => Promise<boolean>;
55
+ listActiveSubscribers: (newsletterSlug: string) => Promise<Subscription[]>;
56
+ send: (options: SendOptions) => Promise<SendResult>;
57
+ };
58
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAGN,KAAK,qBAAqB,EAG1B,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAChC,sEAAsE;IACtE,QAAQ,EAAE,cAAc,CAAC;IACzB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iDAAiD;IACjD,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1E;AAED,MAAM,WAAW,gBAAgB;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC3B,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IAC1B,UAAU,EAAE,UAAU,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7C;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,gBAAgB;sBAczB,MAAM,KAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;yBAchC,gBAAgB,KAAG,OAAO,CAAC,YAAY,CAAC;yBAmDxC,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC;4CAsB1C,MAAM,KACpB,OAAO,CAAC,YAAY,EAAE,CAAC;oBAkBG,WAAW,KAAG,OAAO,CAAC,UAAU,CAAC;CAwD9D,CAAC"}
package/dist/client.js ADDED
@@ -0,0 +1,177 @@
1
+ import { fromAddress, sendEmail } from "@ingram-tech/email";
2
+ import { buildListUnsubscribeHeaders, derivePreviewText, renderNewsletterHtml, renderNewsletterText, } from "./render";
3
+ /**
4
+ * Build a newsletter client bound to a Supabase project. The package owns its
5
+ * tables (see migrations/) and defines its own row types; you inject the client
6
+ * and base URL. Ported from the battle-tested fabrile implementation.
7
+ */
8
+ export const createNewsletter = (config) => {
9
+ const { supabase, baseUrl } = config;
10
+ const unsubscribePath = config.unsubscribePath ?? "/api/newsletter/unsubscribe";
11
+ const normalize = (email) => email.trim().toLowerCase();
12
+ const unsubscribeUrl = (token) => `${baseUrl}${unsubscribePath}?token=${encodeURIComponent(token)}`;
13
+ const renderFn = config.render ??
14
+ ((input) => ({
15
+ html: renderNewsletterHtml(input),
16
+ text: renderNewsletterText(input),
17
+ }));
18
+ const getBySlug = async (slug) => {
19
+ const { data, error } = await supabase
20
+ .from("newsletters")
21
+ .select("*")
22
+ .eq("slug", slug)
23
+ .maybeSingle();
24
+ if (error)
25
+ throw new Error(`Failed to load newsletter: ${error.message}`);
26
+ return data ?? null;
27
+ };
28
+ /**
29
+ * Idempotent subscribe: new email → insert; active sub → noop; previously
30
+ * unsubscribed → resurrect (clear unsubscribed_at).
31
+ */
32
+ const subscribe = async (options) => {
33
+ const email = normalize(options.email);
34
+ const newsletter = await getBySlug(options.newsletterSlug);
35
+ if (!newsletter) {
36
+ throw new Error(`Unknown newsletter: ${options.newsletterSlug}`);
37
+ }
38
+ if (!newsletter.is_active) {
39
+ throw new Error(`Newsletter is not active: ${options.newsletterSlug}`);
40
+ }
41
+ const { data: existingData } = await supabase
42
+ .from("newsletter_subscriptions")
43
+ .select("*")
44
+ .eq("newsletter_id", newsletter.id)
45
+ .eq("email", email)
46
+ .maybeSingle();
47
+ const existing = existingData;
48
+ if (existing) {
49
+ if (!existing.unsubscribed_at && (!options.userId || existing.user_id)) {
50
+ return existing;
51
+ }
52
+ const { data: updated, error } = await supabase
53
+ .from("newsletter_subscriptions")
54
+ .update({
55
+ unsubscribed_at: null,
56
+ source: options.source ?? existing.source,
57
+ user_id: existing.user_id ?? options.userId ?? null,
58
+ })
59
+ .eq("id", existing.id)
60
+ .select()
61
+ .single();
62
+ if (error)
63
+ throw new Error(`Failed to resubscribe: ${error.message}`);
64
+ return updated;
65
+ }
66
+ const { data: inserted, error } = await supabase
67
+ .from("newsletter_subscriptions")
68
+ .insert({
69
+ newsletter_id: newsletter.id,
70
+ email,
71
+ source: options.source ?? null,
72
+ user_id: options.userId ?? null,
73
+ })
74
+ .select()
75
+ .single();
76
+ if (error)
77
+ throw new Error(`Failed to subscribe: ${error.message}`);
78
+ return inserted;
79
+ };
80
+ /** Unsubscribe by token. Idempotent; returns false only for unknown tokens. */
81
+ const unsubscribe = async (token) => {
82
+ const { data, error } = await supabase
83
+ .from("newsletter_subscriptions")
84
+ .select("id, unsubscribed_at")
85
+ .eq("unsubscribe_token", token)
86
+ .maybeSingle();
87
+ if (error)
88
+ throw new Error(`Failed to look up token: ${error.message}`);
89
+ const row = data;
90
+ if (!row)
91
+ return false;
92
+ if (row.unsubscribed_at)
93
+ return true;
94
+ const { error: updateError } = await supabase
95
+ .from("newsletter_subscriptions")
96
+ .update({ unsubscribed_at: new Date().toISOString() })
97
+ .eq("id", row.id);
98
+ if (updateError) {
99
+ throw new Error(`Failed to unsubscribe: ${updateError.message}`);
100
+ }
101
+ return true;
102
+ };
103
+ const listActiveSubscribers = async (newsletterSlug) => {
104
+ const newsletter = await getBySlug(newsletterSlug);
105
+ if (!newsletter)
106
+ throw new Error(`Unknown newsletter: ${newsletterSlug}`);
107
+ const { data, error } = await supabase
108
+ .from("newsletter_subscriptions")
109
+ .select("*")
110
+ .eq("newsletter_id", newsletter.id)
111
+ .is("unsubscribed_at", null)
112
+ .order("subscribed_at", { ascending: true });
113
+ if (error)
114
+ throw new Error(`Failed to list subscribers: ${error.message}`);
115
+ return data ?? [];
116
+ };
117
+ /**
118
+ * Send to all active subscribers (or just `onlyTo`). Each recipient gets a
119
+ * per-row List-Unsubscribe header. Failures are caught per-recipient so one
120
+ * bad address doesn't poison the batch.
121
+ */
122
+ const send = async (options) => {
123
+ const newsletter = await getBySlug(options.newsletterSlug);
124
+ if (!newsletter) {
125
+ throw new Error(`Unknown newsletter: ${options.newsletterSlug}`);
126
+ }
127
+ const subscribers = await listActiveSubscribers(options.newsletterSlug);
128
+ const targets = options.onlyTo
129
+ ? (() => {
130
+ const allowed = new Set(options.onlyTo.map(normalize));
131
+ return subscribers.filter((s) => allowed.has(s.email));
132
+ })()
133
+ : subscribers;
134
+ const fromAddr = fromAddress(newsletter.from_name, newsletter.from_local_part);
135
+ const previewText = options.previewText ?? derivePreviewText(options.content);
136
+ const result = {
137
+ newsletter,
138
+ totalRecipients: targets.length,
139
+ sentCount: 0,
140
+ failedCount: 0,
141
+ failures: [],
142
+ };
143
+ for (const sub of targets) {
144
+ const unsubUrl = unsubscribeUrl(sub.unsubscribe_token);
145
+ try {
146
+ const { html, text } = renderFn({
147
+ newsletterName: newsletter.name,
148
+ subject: options.subject,
149
+ content: options.content,
150
+ cta: options.cta,
151
+ unsubscribeUrl: unsubUrl,
152
+ previewText,
153
+ });
154
+ await sendEmail({
155
+ to: sub.email,
156
+ from: fromAddr,
157
+ replyTo: newsletter.reply_to ?? undefined,
158
+ subject: options.subject,
159
+ html,
160
+ text,
161
+ headers: buildListUnsubscribeHeaders(unsubUrl, fromAddr),
162
+ });
163
+ result.sentCount += 1;
164
+ }
165
+ catch (err) {
166
+ result.failedCount += 1;
167
+ result.failures.push({
168
+ email: sub.email,
169
+ error: err instanceof Error ? err.message : String(err),
170
+ });
171
+ }
172
+ }
173
+ return result;
174
+ };
175
+ return { getBySlug, subscribe, unsubscribe, listActiveSubscribers, send };
176
+ };
177
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export { createNewsletter, type NewsletterConfig, type SendOptions, type SendResult, type SubscribeOptions, } from "./client";
2
+ export { buildListUnsubscribeHeaders, derivePreviewText, type NewsletterRenderInput, renderNewsletterHtml, renderNewsletterText, } from "./render";
3
+ export type { Newsletter, Subscription } from "./types";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,gBAAgB,EAChB,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,gBAAgB,GACrB,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,2BAA2B,EAC3B,iBAAiB,EACjB,KAAK,qBAAqB,EAC1B,oBAAoB,EACpB,oBAAoB,GACpB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createNewsletter, } from "./client";
2
+ export { buildListUnsubscribeHeaders, derivePreviewText, renderNewsletterHtml, renderNewsletterText, } from "./render";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,gBAAgB,GAKhB,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,2BAA2B,EAC3B,iBAAiB,EAEjB,oBAAoB,EACpB,oBAAoB,GACpB,MAAM,UAAU,CAAC"}
@@ -0,0 +1,25 @@
1
+ /** Inputs the default renderer (or a custom one) receives per send. */
2
+ export interface NewsletterRenderInput {
3
+ newsletterName: string;
4
+ subject: string;
5
+ /** Plain-text body; blank lines split paragraphs. */
6
+ content: string;
7
+ cta?: {
8
+ label: string;
9
+ href: string;
10
+ } | null;
11
+ unsubscribeUrl: string;
12
+ /** Inbox-preview headline. */
13
+ previewText?: string;
14
+ }
15
+ /** Minimal, dependency-free HTML email. Override via `createNewsletter({ render })`. */
16
+ export declare const renderNewsletterHtml: (input: NewsletterRenderInput) => string;
17
+ export declare const renderNewsletterText: (input: NewsletterRenderInput) => string;
18
+ /**
19
+ * RFC 8058 List-Unsubscribe headers for one-click unsubscribe in Gmail/Apple
20
+ * Mail. `fromAddr` may be "Name <local@domain>" or a bare address.
21
+ */
22
+ export declare const buildListUnsubscribeHeaders: (unsubscribeUrl: string, fromAddr: string) => Record<string, string>;
23
+ /** First non-empty line, trimmed to ~140 chars — a sensible preview default. */
24
+ export declare const derivePreviewText: (content: string) => string;
25
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,MAAM,WAAW,qBAAqB;IACrC,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC7C,cAAc,EAAE,MAAM,CAAC;IACvB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAUD,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,GAAI,OAAO,qBAAqB,KAAG,MAuBnE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,OAAO,qBAAqB,KAAG,MAGkD,CAAC;AAEvH;;;GAGG;AACH,eAAO,MAAM,2BAA2B,GACvC,gBAAgB,MAAM,EACtB,UAAU,MAAM,KACd,MAAM,CAAC,MAAM,EAAE,MAAM,CAOvB,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,iBAAiB,GAAI,SAAS,MAAM,KAAG,MAOnD,CAAC"}
package/dist/render.js ADDED
@@ -0,0 +1,50 @@
1
+ const escapeHtml = (value) => value
2
+ .replace(/&/g, "&amp;")
3
+ .replace(/</g, "&lt;")
4
+ .replace(/>/g, "&gt;")
5
+ .replace(/"/g, "&quot;")
6
+ .replace(/'/g, "&#39;");
7
+ /** Minimal, dependency-free HTML email. Override via `createNewsletter({ render })`. */
8
+ export const renderNewsletterHtml = (input) => {
9
+ const paragraphs = input.content
10
+ .split(/\n\s*\n/)
11
+ .map((p) => p.trim())
12
+ .filter(Boolean)
13
+ .map((p) => `<p style="margin:0 0 16px;line-height:1.6;">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
14
+ .join("\n");
15
+ const cta = input.cta
16
+ ? `<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>`
17
+ : "";
18
+ return `<!doctype html><html><body style="font-family:Arial,Helvetica,sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#111;">
19
+ <div style="display:none;max-height:0;overflow:hidden;">${escapeHtml(input.previewText ?? "")}</div>
20
+ <h1 style="font-size:20px;margin:0 0 16px;">${escapeHtml(input.subject)}</h1>
21
+ ${paragraphs}
22
+ ${cta}
23
+ <hr style="border:none;border-top:1px solid #e5e5e5;margin:32px 0 16px;"/>
24
+ <p style="font-size:12px;color:#888;">You're receiving this because you subscribed to ${escapeHtml(input.newsletterName)}. <a href="${escapeHtml(input.unsubscribeUrl)}" style="color:#888;">Unsubscribe</a>.</p>
25
+ </body></html>`;
26
+ };
27
+ export const renderNewsletterText = (input) => `${input.subject}\n\n${input.content}\n\n${input.cta ? `${input.cta.label}: ${input.cta.href}\n\n` : ""}---\nYou're receiving this because you subscribed to ${input.newsletterName}.\nUnsubscribe: ${input.unsubscribeUrl}`;
28
+ /**
29
+ * RFC 8058 List-Unsubscribe headers for one-click unsubscribe in Gmail/Apple
30
+ * Mail. `fromAddr` may be "Name <local@domain>" or a bare address.
31
+ */
32
+ export const buildListUnsubscribeHeaders = (unsubscribeUrl, fromAddr) => {
33
+ const match = fromAddr.match(/<([^>]+)>/);
34
+ const mailto = match?.[1] ?? fromAddr;
35
+ return {
36
+ "List-Unsubscribe": `<${unsubscribeUrl}>, <mailto:${mailto}?subject=unsubscribe>`,
37
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
38
+ };
39
+ };
40
+ /** First non-empty line, trimmed to ~140 chars — a sensible preview default. */
41
+ export const derivePreviewText = (content) => {
42
+ const first = content
43
+ .split("\n")
44
+ .map((line) => line.trim())
45
+ .find((line) => line.length > 0);
46
+ if (!first)
47
+ return "";
48
+ return first.length > 140 ? `${first.slice(0, 137)}…` : first;
49
+ };
50
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /** Row of the `newsletters` table (see migrations/0001_newsletters.sql). */
2
+ export interface Newsletter {
3
+ id: string;
4
+ slug: string;
5
+ name: string;
6
+ description: string | null;
7
+ from_name: string;
8
+ from_local_part: string;
9
+ reply_to: string | null;
10
+ is_active: boolean;
11
+ created_at: string;
12
+ updated_at: string;
13
+ }
14
+ /** Row of the `newsletter_subscriptions` table. */
15
+ export interface Subscription {
16
+ id: string;
17
+ newsletter_id: string;
18
+ email: string;
19
+ user_id: string | null;
20
+ unsubscribe_token: string;
21
+ subscribed_at: string;
22
+ unsubscribed_at: string | null;
23
+ source: string | null;
24
+ metadata: Record<string, unknown>;
25
+ created_at: string;
26
+ updated_at: string;
27
+ }
28
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,MAAM,WAAW,UAAU;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,mDAAmD;AACnD,MAAM,WAAW,YAAY;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACnB"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,88 @@
1
+ -- @ingram-tech/newsletter — base schema.
2
+ --
3
+ -- Portable across any Supabase project: standard UUID primary keys, no
4
+ -- project-specific ID helpers. Two tables:
5
+ -- newsletters — registry of lists (slug, name, sender)
6
+ -- newsletter_subscriptions — one row per (newsletter, email), optionally
7
+ -- linked to an auth user (cascade-deleted with it)
8
+ --
9
+ -- This file owns the package's tables. Apply it into your supabase/migrations.
10
+ -- For auto-linking subscriptions to users on signup, also apply
11
+ -- 0002_newsletters_auth_link.sql (optional).
12
+
13
+ create extension if not exists "pgcrypto" with schema "extensions";
14
+
15
+ create table if not exists "public"."newsletters" (
16
+ "id" uuid primary key default gen_random_uuid(),
17
+ "slug" text not null unique,
18
+ "name" text not null,
19
+ "description" text,
20
+ "from_name" text not null,
21
+ "from_local_part" text not null default 'news',
22
+ "reply_to" text,
23
+ "is_active" boolean not null default true,
24
+ "created_at" timestamptz not null default now(),
25
+ "updated_at" timestamptz not null default now(),
26
+ constraint "newsletters_slug_format" check ("slug" ~ '^[a-z0-9][a-z0-9-]*$'),
27
+ constraint "newsletters_from_local_part_format" check ("from_local_part" ~ '^[a-z0-9][a-z0-9._-]*$')
28
+ );
29
+
30
+ comment on table "public"."newsletters" is 'Registry of newsletters. The sending address is "<from_local_part>@<EMAIL_FROM_DOMAIN>".';
31
+
32
+ create table if not exists "public"."newsletter_subscriptions" (
33
+ "id" uuid primary key default gen_random_uuid(),
34
+ "newsletter_id" uuid not null references "public"."newsletters" ("id") on delete cascade,
35
+ "email" text not null,
36
+ "user_id" uuid references "auth"."users" ("id") on delete cascade,
37
+ "unsubscribe_token" text not null unique default encode("extensions"."gen_random_bytes" (32), 'hex'),
38
+ "subscribed_at" timestamptz not null default now(),
39
+ "unsubscribed_at" timestamptz,
40
+ "source" text,
41
+ "metadata" jsonb not null default '{}'::jsonb,
42
+ "created_at" timestamptz not null default now(),
43
+ "updated_at" timestamptz not null default now(),
44
+ constraint "newsletter_subscriptions_email_lower" check ("email" = lower("email")),
45
+ constraint "newsletter_subscriptions_email_format" check ("email" ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$'),
46
+ constraint "newsletter_subscriptions_newsletter_email_key" unique ("newsletter_id", "email")
47
+ );
48
+
49
+ comment on column "public"."newsletter_subscriptions"."unsubscribe_token" is '256-bit random token used in the List-Unsubscribe URL; regenerated per row, never exposes the id.';
50
+ comment on column "public"."newsletter_subscriptions"."unsubscribed_at" is 'NULL while subscribed; set on unsubscribe. Row is kept so re-subscriptions are detectable.';
51
+
52
+ create index if not exists "idx_newsletter_subscriptions_newsletter_id" on "public"."newsletter_subscriptions" ("newsletter_id");
53
+ create index if not exists "idx_newsletter_subscriptions_email" on "public"."newsletter_subscriptions" ("email");
54
+ create index if not exists "idx_newsletter_subscriptions_user_id" on "public"."newsletter_subscriptions" ("user_id") where "user_id" is not null;
55
+ -- Hot path: enumerating active subscribers for a send.
56
+ create index if not exists "idx_newsletter_subscriptions_active" on "public"."newsletter_subscriptions" ("newsletter_id") where "unsubscribed_at" is null;
57
+
58
+ -- updated_at maintenance
59
+ create or replace function "public"."newsletters_set_updated_at" () returns "trigger" language "plpgsql" set "search_path" to '' as $$
60
+ begin
61
+ new.updated_at = now();
62
+ return new;
63
+ end;
64
+ $$;
65
+
66
+ drop trigger if exists "newsletters_updated_at" on "public"."newsletters";
67
+ create trigger "newsletters_updated_at" before update on "public"."newsletters" for each row execute function "public"."newsletters_set_updated_at" ();
68
+
69
+ drop trigger if exists "newsletter_subscriptions_updated_at" on "public"."newsletter_subscriptions";
70
+ create trigger "newsletter_subscriptions_updated_at" before update on "public"."newsletter_subscriptions" for each row execute function "public"."newsletters_set_updated_at" ();
71
+
72
+ -- Row Level Security.
73
+ alter table "public"."newsletters" enable row level security;
74
+ alter table "public"."newsletter_subscriptions" enable row level security;
75
+
76
+ -- newsletters: publicly readable (preference pages list them). Writes are
77
+ -- service-role only (admin tooling / seeds).
78
+ drop policy if exists "Newsletters are publicly readable" on "public"."newsletters";
79
+ create policy "Newsletters are publicly readable" on "public"."newsletters" for select using (true);
80
+
81
+ -- subscriptions: authenticated users manage only their own rows. Anonymous
82
+ -- subscribe/unsubscribe goes through service-role API routes.
83
+ drop policy if exists "Users can view their own subscriptions" on "public"."newsletter_subscriptions";
84
+ create policy "Users can view their own subscriptions" on "public"."newsletter_subscriptions" for select using ("user_id" = "auth"."uid" ());
85
+ drop policy if exists "Users can update their own subscriptions" on "public"."newsletter_subscriptions";
86
+ create policy "Users can update their own subscriptions" on "public"."newsletter_subscriptions" for update using ("user_id" = "auth"."uid" ());
87
+ drop policy if exists "Users can delete their own subscriptions" on "public"."newsletter_subscriptions";
88
+ create policy "Users can delete their own subscriptions" on "public"."newsletter_subscriptions" for delete using ("user_id" = "auth"."uid" ());
@@ -0,0 +1,22 @@
1
+ -- @ingram-tech/newsletter — OPTIONAL auth-linking add-on.
2
+ --
3
+ -- Apply this ONLY on sites that have user signups and want a subscription made
4
+ -- before signup (e.g. a blog-footer signup) to be back-linked to the user when
5
+ -- they later create an account with the same email. Requires 0001 first.
6
+ --
7
+ -- Marketing sites without auth should NOT apply this.
8
+
9
+ create or replace function "public"."link_newsletter_subscriptions_on_signup" () returns "trigger" language "plpgsql" security definer set "search_path" to '' as $$
10
+ begin
11
+ update public.newsletter_subscriptions
12
+ set user_id = new.id
13
+ where user_id is null
14
+ and email = lower(new.email);
15
+ return new;
16
+ end;
17
+ $$;
18
+
19
+ drop trigger if exists "link_newsletter_subscriptions_on_signup" on "auth"."users";
20
+ create trigger "link_newsletter_subscriptions_on_signup"
21
+ after insert on "auth"."users" for each row
22
+ execute function "public"."link_newsletter_subscriptions_on_signup" ();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@ingram-tech/newsletter",
3
+ "version": "0.1.0",
4
+ "description": "Supabase-backed newsletter subscriptions + sending, with RFC 8058 one-click unsubscribe.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ingram-technologies/nextkit.git",
10
+ "directory": "packages/newsletter"
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/email": "^0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@ingram-tech/typescript-config": "0.1.0",
36
+ "@supabase/supabase-js": "^2.45.0",
37
+ "@types/node": "^20.0.0",
38
+ "typescript": "^6.0.3",
39
+ "vitest": "^4.1.6"
40
+ },
41
+ "peerDependencies": {
42
+ "@supabase/supabase-js": ">=2.0.0"
43
+ }
44
+ }