@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 +51 -0
- package/dist/index.js +2 -0
- package/dist/src/campaigns.d.ts +24 -0
- package/dist/src/headers.d.ts +25 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/lists.d.ts +8 -0
- package/dist/src/newsletter.d.ts +17 -0
- package/dist/src/subscriptions.d.ts +5 -0
- package/dist/src/types.d.ts +64 -0
- package/package.json +60 -0
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,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
|
+
}
|