@stacksjs/newsletter 0.70.23

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,51 @@
1
+ # @stacksjs/newsletter
2
+
3
+ Promotional email + newsletter primitives for Stacks. Sits on top of
4
+ `@stacksjs/email` — does not introduce a new transport.
5
+
6
+ ```ts
7
+ import { newsletter } from '@stacksjs/newsletter'
8
+
9
+ // list management
10
+ const list = await newsletter.lists.create({ name: 'Weekly Digest', slug: 'weekly' })
11
+
12
+ // subscribe / unsubscribe (returns the per-list unsubscribe token)
13
+ await newsletter.subscribe('jane@example.com', { list: 'weekly', source: 'homepage' })
14
+ await newsletter.unsubscribe(token)
15
+
16
+ // campaigns
17
+ const campaign = await newsletter.campaigns.create({
18
+ name: 'May Edition',
19
+ subject: 'What\'s new this month',
20
+ template: 'newsletter-may',
21
+ emailListId: list.id,
22
+ })
23
+
24
+ await newsletter.campaigns.sendNow(campaign.id) // dispatches SendCampaignJob
25
+ await newsletter.campaigns.schedule(campaign.id, new Date('2026-05-15T09:00:00Z'))
26
+ ```
27
+
28
+ ## What this package owns
29
+
30
+ - `EmailList`, `Campaign`, `CampaignSend`, `EmailListSubscriber` (pivot)
31
+ - Per-list, per-subscriber unsubscribe tokens
32
+ - `List-Unsubscribe` + `List-Unsubscribe-Post` headers (RFC 8058)
33
+ - Chunked send via `SendCampaignJob` → `mail.send()` per recipient
34
+
35
+ ## What this package does NOT own
36
+
37
+ - Email transports (use `@stacksjs/email` drivers — SES, SendGrid, Mailgun, …)
38
+ - Open / click tracking pixels (planned)
39
+ - Bounce / complaint webhooks (planned, per driver)
40
+ - Drip / automation sequences (planned)
41
+ - Provider-side audience sync (planned, opt-in)
42
+
43
+ ## Deliverability checklist
44
+
45
+ This package can scaffold the headers and unsubscribe loop. Sending reputation
46
+ is on you:
47
+
48
+ - DKIM, SPF, and DMARC must pass on your sending domain
49
+ - Warm dedicated IPs gradually if you're sending serious volume
50
+ - Honor unsubscribes within the same business day
51
+ - Monitor complaint rate (target < 0.1%) — exceeding 0.3% gets you blocked
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ var H=import.meta.require;function K(x){return x.toLowerCase().trim().replace(/[^\w\s-]+/g,"").replace(/\s+/g,"-").replace(/-+/g,"-")}var J={async create(x){let{EmailList:z}=await import("@stacksjs/orm"),D=x.slug??K(x.name);return z.create({name:x.name,slug:D,description:x.description??null,status:"active",isPublic:x.isPublic===!1?0:1,doubleOptIn:x.doubleOptIn===!1?0:1,subscriberCount:0,activeCount:0,unsubscribedCount:0,bouncedCount:0})},async find(x){let{EmailList:z}=await import("@stacksjs/orm");if(typeof x==="number")return z.find(x);return z.where("slug",x).first()},async all(){let{EmailList:x}=await import("@stacksjs/orm");return x.where("status","active").get()},async archive(x){let z=await J.find(x);if(!z)throw Error(`[newsletter] List '${String(x)}' not found`);return z.update({status:"archived"})}};async function P(x){if(x.emailListId)return x.emailListId;if(x.emailListSlug){let z=await J.find(x.emailListSlug);if(!z)throw Error(`[newsletter] List '${x.emailListSlug}' not found`);return z.id}throw Error("[newsletter] Campaign requires emailListId or emailListSlug")}var W={async create(x){let{Campaign:z}=await import("@stacksjs/orm"),D=await P(x),F=x.scheduledAt?"scheduled":"draft";return z.create({name:x.name,description:x.description??null,type:"email",status:F,subject:x.subject,template:x.template,text:x.text??null,from_name:x.fromName??null,from_address:x.fromAddress??null,email_list_id:D,scheduled_at:x.scheduledAt??null,sentCount:0})},async find(x){let{Campaign:z}=await import("@stacksjs/orm");return z.find(x)},async update(x,z){let D=await W.find(x);if(!D)throw Error(`[newsletter] Campaign ${x} not found`);if(D.status!=="draft"&&D.status!=="scheduled")throw Error(`[newsletter] Cannot edit campaign in status '${D.status}'`);return D.update(z)},async sendNow(x,z={}){let D=await W.find(x);if(!D)throw Error(`[newsletter] Campaign ${x} not found`);if(D.status==="sending"||D.status==="sent")throw Error(`[newsletter] Campaign ${x} already ${D.status}`);await D.update({status:"sending"});let{job:F}=await import("@stacksjs/queue");return await F("SendCampaign",{campaignId:x,chunkSize:z.chunkSize??50,dryRun:z.dryRun??!1}).onQueue("campaigns").dispatch(),{ok:!0,campaignId:x}},async schedule(x,z,D={}){let F=await W.find(x);if(!F)throw Error(`[newsletter] Campaign ${x} not found`);let G=z instanceof Date?z:new Date(z),M=Math.max(0,Math.floor((G.getTime()-Date.now())/1000));await F.update({status:M===0?"sending":"scheduled",scheduled_at:G.toISOString()});let{job:V}=await import("@stacksjs/queue");return await V("SendCampaign",{campaignId:x,chunkSize:D.chunkSize??50,dryRun:D.dryRun??!1}).onQueue("campaigns").delay(M).dispatch(),{ok:!0,campaignId:x,scheduledAt:G.toISOString()}},async cancel(x){let z=await W.find(x);if(!z)throw Error(`[newsletter] Campaign ${x} not found`);if(z.status==="sent")throw Error(`[newsletter] Campaign ${x} already sent \u2014 cannot cancel`);return z.update({status:"cancelled"})}};function U(x){if(!x.url||!/^https?:\/\//i.test(x.url))throw Error("[newsletter] List-Unsubscribe URL must be absolute (http/https)");let z=[];if(x.mailto)z.push(`<mailto:${x.mailto}>`);return z.push(`<${x.url}>`),{"List-Unsubscribe":z.join(", "),"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}async function Q(x){if(x==null){let D=await J.find("default");if(!D)D=await J.create({name:"Default",slug:"default"});return D.id}let z=await J.find(x);if(!z)throw Error(`[newsletter] List '${String(x)}' not found`);return z.id}async function Y(x,z={}){if(!x||!x.includes("@"))throw Error("[newsletter] subscribe() requires a valid email address");let{Subscriber:D,EmailListSubscriber:F}=await import("@stacksjs/orm"),G=await Q(z.list),M=z.source??"api",V=await D.where("email",x).first();if(!V)V=await D.create({email:x,status:"subscribed",source:M});let X=await F.where("subscriber_id",V.id).where("email_list_id",G).first();if(X){if(X.status==="unsubscribed")await X.update({status:"subscribed",unsubscribed_at:null});return{created:!1,email:x,listId:G,token:X.uuid}}let B=await F.create({subscriber_id:V.id,email_list_id:G,status:"subscribed",source:M});return{created:!0,email:x,listId:G,token:B.uuid}}async function Z(x){if(!x)return{ok:!1};let{EmailListSubscriber:z,Subscriber:D}=await import("@stacksjs/orm"),F=await z.where("uuid",x).first();if(!F)return{ok:!1};if(F.status==="unsubscribed")return{ok:!0,alreadyUnsubscribed:!0,email:(await D.find(F.subscriber_id))?.email,listId:F.email_list_id};return await F.update({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}),{ok:!0,email:(await D.find(F.subscriber_id))?.email,listId:F.email_list_id}}async function $(x){let{Subscriber:z,EmailListSubscriber:D}=await import("@stacksjs/orm"),F=await z.where("email",x).first();if(!F)return 0;let G=await D.where("subscriber_id",F.id).where("status","subscribed").get();for(let M of G)await M.update({status:"unsubscribed",unsubscribed_at:new Date().toISOString()});return G.length}var f={lists:J,campaigns:W,subscribe:Y,unsubscribe:Z,unsubscribeAll:$};export{$ as unsubscribeAll,Z as unsubscribe,Y as subscribe,f as newsletter,J as lists,W as campaigns,U as buildUnsubscribeHeaders};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @defaultValue
3
+ * ```ts
4
+ * {
5
+ * create: () => unknown,
6
+ * find: () => unknown,
7
+ * update: () => unknown,
8
+ * cancel: () => unknown
9
+ * }
10
+ * ```
11
+ */
12
+ export declare const campaigns: {
13
+ create: (input: CreateCampaignInput) => Promise<void>;
14
+ find: (id: number) => Promise<void>;
15
+ update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<void>;
16
+ /** Move a draft straight into the queue. */
17
+ async sendNow: (id: number, options?: SendCampaignOptions) => unknown;
18
+ /**
19
+ * Persist a `scheduled_at` and dispatch the job with a delay computed
20
+ * from now. If the time is in the past, falls back to immediate send.
21
+ */
22
+ async schedule: (id: number, scheduledAt: Date | string, options?: SendCampaignOptions) => unknown;
23
+ cancel: (id: number) => Promise<void>
24
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Build the headers for an outbound campaign email.
3
+ *
4
+ * Returns a flat header map that drivers can copy into the outgoing
5
+ * MIME envelope.
6
+ */
7
+ export declare function buildUnsubscribeHeaders(opts: UnsubscribeHeaderOptions): Record<string, string>;
8
+ /**
9
+ * RFC 8058 / RFC 2369 List-Unsubscribe header construction.
10
+ *
11
+ * Why both mailto: and https://? Some clients (older Outlook, some
12
+ * webmails) only honor `mailto:`; modern clients prefer the one-click
13
+ * HTTPS form. Including both lets every client unsubscribe without
14
+ * forcing the user to look for a tiny in-body link.
15
+ *
16
+ * `List-Unsubscribe-Post: List-Unsubscribe=One-Click` is what tells
17
+ * Gmail/Yahoo/etc. that POSTing to the https URL with no body is a
18
+ * valid one-click unsubscribe. Without that header those mailbox
19
+ * providers fall back to *clicking* the URL — which means a normal
20
+ * GET handler. RFC 8058 clients will POST.
21
+ */
22
+ export declare interface UnsubscribeHeaderOptions {
23
+ url: string
24
+ mailto?: string
25
+ }
@@ -0,0 +1,6 @@
1
+ export * from './campaigns';
2
+ export * from './headers';
3
+ export * from './lists';
4
+ export * from './newsletter';
5
+ export * from './subscriptions';
6
+ export * from './types';
@@ -0,0 +1,8 @@
1
+ /** @defaultValue `{ create: () => unknown, all: () => unknown, archive: () => unknown }` */
2
+ export declare const lists: {
3
+ create: (input: CreateListInput) => Promise<void>;
4
+ /** Look up by slug first, then by id — slugs are the public-facing handle. */
5
+ async find: (idOrSlug: number | string) => unknown;
6
+ all: () => Promise<void>;
7
+ archive: (idOrSlug: number | string) => Promise<void>
8
+ };
@@ -0,0 +1,17 @@
1
+ import { campaigns } from './campaigns';
2
+ import { lists } from './lists';
3
+ import { subscribe, unsubscribe, unsubscribeAll } from './subscriptions';
4
+ /**
5
+ * Top-level Newsletter facade.
6
+ *
7
+ * Keeps the import surface tiny — most call sites only need
8
+ * `newsletter.subscribe()` or `newsletter.campaigns.sendNow()`.
9
+ */
10
+ export declare const newsletter: {
11
+ lists: typeof lists
12
+ campaigns: typeof campaigns
13
+ subscribe: typeof subscribe
14
+ unsubscribe: typeof unsubscribe
15
+ unsubscribeAll: typeof unsubscribeAll
16
+ };
17
+ export { campaigns, lists, subscribe, unsubscribe, unsubscribeAll };
@@ -0,0 +1,5 @@
1
+ import type { SubscribeOptions, SubscribeResult, UnsubscribeResult } from './types';
2
+ export declare function subscribe(email: string, options?: SubscribeOptions): Promise<SubscribeResult>;
3
+ export declare function unsubscribe(token: string): Promise<UnsubscribeResult>;
4
+ /** Bulk unsubscribe by email — used by bounce/complaint handlers. */
5
+ export declare function unsubscribeAll(email: string): Promise<number>;
@@ -0,0 +1,64 @@
1
+ export declare interface CreateListInput {
2
+ name: string
3
+ slug?: string
4
+ description?: string
5
+ doubleOptIn?: boolean
6
+ isPublic?: boolean
7
+ }
8
+ export declare interface CreateCampaignInput {
9
+ name: string
10
+ subject: string
11
+ template: string
12
+ text?: string
13
+ emailListId?: number
14
+ emailListSlug?: string
15
+ fromName?: string
16
+ fromAddress?: string
17
+ scheduledAt?: string
18
+ description?: string
19
+ }
20
+ export declare interface SubscribeOptions {
21
+ list?: string | number
22
+ source?: string
23
+ upsert?: boolean
24
+ }
25
+ export declare interface SubscribeResult {
26
+ created: boolean
27
+ email: string
28
+ listId: number
29
+ token: string
30
+ }
31
+ export declare interface UnsubscribeResult {
32
+ ok: boolean
33
+ email?: string
34
+ listId?: number
35
+ alreadyUnsubscribed?: boolean
36
+ }
37
+ export declare interface SendCampaignOptions {
38
+ chunkSize?: number
39
+ dryRun?: boolean
40
+ }
41
+ /**
42
+ * Public types for the @stacksjs/newsletter package.
43
+ *
44
+ * The shapes here describe the *facade input/output*, not the underlying
45
+ * model rows — that lets us keep the public API stable even as model
46
+ * attribute lists evolve.
47
+ */
48
+ export type EmailListStatus = 'active' | 'inactive' | 'archived';
49
+ export type CampaignStatus = | 'draft'
50
+ | 'scheduled'
51
+ | 'sending'
52
+ | 'sent'
53
+ | 'paused'
54
+ | 'cancelled'
55
+ | 'failed';
56
+ export type SubscriptionStatus = | 'subscribed'
57
+ | 'unsubscribed'
58
+ | 'pending'
59
+ | 'bounced';
60
+ export type CampaignSendStatus = | 'queued'
61
+ | 'sent'
62
+ | 'failed'
63
+ | 'bounced'
64
+ | 'complained';
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@stacksjs/newsletter",
3
+ "type": "module",
4
+ "version": "0.70.23",
5
+ "description": "Promotional email & newsletter primitives for Stacks. Lists, campaigns, subscribers, and one-click unsubscribe — sent through @stacksjs/email.",
6
+ "author": "Chris Breuer",
7
+ "contributors": [
8
+ "Chris Breuer <chris@stacksjs.com>"
9
+ ],
10
+ "license": "MIT",
11
+ "funding": "https://github.com/sponsors/chrisbbreuer",
12
+ "homepage": "https://github.com/stacksjs/stacks/tree/main/storage/framework/core/newsletter#readme",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/stacksjs/stacks.git",
16
+ "directory": "./storage/framework/core/newsletter"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/stacksjs/stacks/issues"
20
+ },
21
+ "keywords": [
22
+ "newsletter",
23
+ "marketing",
24
+ "email",
25
+ "campaigns",
26
+ "stacks",
27
+ "broadcast",
28
+ "promotional",
29
+ "subscribers"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "bun": "./src/index.ts",
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ },
37
+ "./*": {
38
+ "bun": "./src/*",
39
+ "import": "./dist/*"
40
+ }
41
+ },
42
+ "module": "dist/index.js",
43
+ "types": "dist/index.d.ts",
44
+ "files": [
45
+ "README.md",
46
+ "dist"
47
+ ],
48
+ "scripts": {
49
+ "build": "bun build.ts",
50
+ "typecheck": "bun tsc --noEmit",
51
+ "prepublishOnly": "bun run build"
52
+ },
53
+ "devDependencies": {
54
+ "@stacksjs/cli": "0.70.23",
55
+ "@stacksjs/config": "0.70.23",
56
+ "better-dx": "^0.2.12",
57
+ "@stacksjs/error-handling": "0.70.23",
58
+ "@stacksjs/types": "0.70.23"
59
+ }
60
+ }