@stacksjs/newsletter 0.70.88 → 0.70.91
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/dist/campaigns.d.ts +37 -0
- package/dist/headers.d.ts +25 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/lists.d.ts +26 -0
- package/dist/newsletter.d.ts +17 -0
- package/dist/subscriptions.d.ts +5 -0
- package/dist/types.d.ts +64 -0
- package/package.json +5 -5
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Campaign CRUD + dispatch.
|
|
3
|
+
*
|
|
4
|
+
* `sendNow()` and `schedule()` both end up dispatching the
|
|
5
|
+
* `SendCampaignJob` queue handler — `schedule()` just adds a `delay()`
|
|
6
|
+
* computed against the campaign's `scheduled_at`. The job is the one
|
|
7
|
+
* source of truth for actually fanning out emails so that a manual
|
|
8
|
+
* `sendNow()` from the dashboard and a cron-fired scheduled run go
|
|
9
|
+
* through identical machinery.
|
|
10
|
+
*/
|
|
11
|
+
declare function resolveListId(input: CreateCampaignInput): Promise<number>;
|
|
12
|
+
/**
|
|
13
|
+
* @defaultValue
|
|
14
|
+
* ```ts
|
|
15
|
+
* {
|
|
16
|
+
* create: () => unknown,
|
|
17
|
+
* find: () => unknown,
|
|
18
|
+
* update: () => unknown,
|
|
19
|
+
* sendNow: () => unknown,
|
|
20
|
+
* schedule: () => unknown,
|
|
21
|
+
* cancel: () => unknown
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare const campaigns: {
|
|
26
|
+
create: (input: CreateCampaignInput) => Promise<void>;
|
|
27
|
+
find: (id: number) => Promise<void>;
|
|
28
|
+
update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<void>;
|
|
29
|
+
/** Move a draft straight into the queue. */
|
|
30
|
+
sendNow: (id: number, options?: SendCampaignOptions) => Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Persist a `scheduled_at` and dispatch the job with a delay computed
|
|
33
|
+
* from now. If the time is in the past, falls back to immediate send.
|
|
34
|
+
*/
|
|
35
|
+
schedule: (id: number, scheduledAt: Date | string, options?: SendCampaignOptions) => Promise<void>;
|
|
36
|
+
cancel: (id: number) => Promise<void>
|
|
37
|
+
};
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var H=import.meta.require;function B(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??B(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 K(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 V={async create(x){let{Campaign:z}=await import("@stacksjs/orm"),D=await K(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 V.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 V.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 V.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:Q}=await import("@stacksjs/queue");return await Q("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 V.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 P(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 X(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 P(z.list),M=z.source??"api",Q=await D.where("email",x).first();if(!Q)Q=await D.create({email:x,status:"subscribed",source:M});let W=await F.where("subscriber_id",Q.id).where("email_list_id",G).first();if(W){if(W.status==="unsubscribed")await W.update({status:"subscribed",unsubscribed_at:null});return{created:!1,email:x,listId:G,token:W.uuid}}let $=await F.create({subscriber_id:Q.id,email_list_id:G,status:"subscribed",source:M});return{created:!0,email:x,listId:G,token:$.uuid}}async function Y(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 Z(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 I={lists:J,campaigns:V,subscribe:X,unsubscribe:Y,unsubscribeAll:Z};export{Z as unsubscribeAll,Y as unsubscribe,X as subscribe,I as newsletter,J as lists,V as campaigns,U as buildUnsubscribeHeaders};
|
package/dist/lists.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailList CRUD wrapper.
|
|
3
|
+
*
|
|
4
|
+
* The model lives in `defaults/app/Models/EmailList.ts`; here we expose
|
|
5
|
+
* a friendlier surface for application code so callers don't depend on
|
|
6
|
+
* raw ORM idioms (which changes across releases).
|
|
7
|
+
*/
|
|
8
|
+
declare function slugify(input: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* @defaultValue
|
|
11
|
+
* ```ts
|
|
12
|
+
* {
|
|
13
|
+
* create: () => unknown,
|
|
14
|
+
* find: () => unknown,
|
|
15
|
+
* all: () => unknown,
|
|
16
|
+
* archive: () => unknown
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare const lists: {
|
|
21
|
+
create: (input: CreateListInput) => Promise<void>;
|
|
22
|
+
/** Look up by slug first, then by id — slugs are the public-facing handle. */
|
|
23
|
+
find: (idOrSlug: number | string) => Promise<void>;
|
|
24
|
+
all: () => Promise<void>;
|
|
25
|
+
archive: (idOrSlug: number | string) => Promise<void>
|
|
26
|
+
};
|
|
@@ -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>;
|
package/dist/types.d.ts
ADDED
|
@@ -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
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/newsletter",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.91",
|
|
6
6
|
"description": "Promotional email & newsletter primitives for Stacks. Lists, campaigns, subscribers, and one-click unsubscribe — sent through @stacksjs/email.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
"prepublishOnly": "bun run build"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@stacksjs/cli": "0.70.
|
|
60
|
-
"@stacksjs/config": "0.70.
|
|
59
|
+
"@stacksjs/cli": "0.70.91",
|
|
60
|
+
"@stacksjs/config": "0.70.91",
|
|
61
61
|
"better-dx": "^0.2.16",
|
|
62
|
-
"@stacksjs/error-handling": "0.70.
|
|
63
|
-
"@stacksjs/types": "0.70.
|
|
62
|
+
"@stacksjs/error-handling": "0.70.91",
|
|
63
|
+
"@stacksjs/types": "0.70.91"
|
|
64
64
|
}
|
|
65
65
|
}
|