@stacksjs/newsletter 0.72.9 → 0.72.11
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 +25 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/lists.d.ts +32 -8
- package/dist/sync.d.ts +55 -0
- package/package.json +8 -8
package/dist/campaigns.d.ts
CHANGED
|
@@ -22,9 +22,9 @@ export declare function campaignUpdateData(patch: Partial<CreateCampaignInput>,
|
|
|
22
22
|
* @defaultValue
|
|
23
23
|
* ```ts
|
|
24
24
|
* {
|
|
25
|
-
* create: (input: CreateCampaignInput) => Promise<
|
|
26
|
-
* find: (id: number) => Promise<
|
|
27
|
-
* update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<
|
|
25
|
+
* create: (input: CreateCampaignInput) => Promise<CampaignRow>,
|
|
26
|
+
* find: (id: number) => Promise<CampaignRow | undefined>,
|
|
27
|
+
* update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<unknown>,
|
|
28
28
|
* sendNow: (id: number, options?: SendCampaignOptions) => Promise<void>,
|
|
29
29
|
* schedule: (id: number, scheduledAt: Date | string, options?: SendCampaignOptions) => Promise<void>,
|
|
30
30
|
* cancel: (id: number) => Promise<void>
|
|
@@ -32,9 +32,9 @@ export declare function campaignUpdateData(patch: Partial<CreateCampaignInput>,
|
|
|
32
32
|
* ```
|
|
33
33
|
*/
|
|
34
34
|
export declare const campaigns: {
|
|
35
|
-
create: (input: CreateCampaignInput) => Promise<
|
|
36
|
-
find: (id: number) => Promise<
|
|
37
|
-
update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<
|
|
35
|
+
create: (input: CreateCampaignInput) => Promise<CampaignRow>;
|
|
36
|
+
find: (id: number) => Promise<CampaignRow | undefined>;
|
|
37
|
+
update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<unknown>;
|
|
38
38
|
/** Move a draft straight into the queue. */
|
|
39
39
|
sendNow: (id: number, options?: SendCampaignOptions) => Promise<void>;
|
|
40
40
|
/**
|
|
@@ -60,6 +60,25 @@ declare interface CampaignDeliveryTarget {
|
|
|
60
60
|
updatedAt: string
|
|
61
61
|
attemptId: string
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* A campaign row as callers use it.
|
|
65
|
+
*
|
|
66
|
+
* Same reason as `EmailListRow`: the models arrive through
|
|
67
|
+
* `await import(...) as any`, so there is nothing to infer a return type
|
|
68
|
+
* from, and the published types said `Promise<void>` - which made
|
|
69
|
+
* `campaigns.create()` useless to anyone who needed the new campaign's id in
|
|
70
|
+
* order to send it.
|
|
71
|
+
*/
|
|
72
|
+
export declare interface CampaignRow {
|
|
73
|
+
id: number
|
|
74
|
+
name: string
|
|
75
|
+
subject?: string | null
|
|
76
|
+
status: string
|
|
77
|
+
scheduled_at?: string | null
|
|
78
|
+
sent_count?: number
|
|
79
|
+
update: (data: Record<string, unknown>) => Promise<unknown>
|
|
80
|
+
[key: string]: unknown
|
|
81
|
+
}
|
|
63
82
|
declare type CampaignWriteValue = string | number | null;
|
|
64
83
|
export declare class CampaignStateConflictError extends Error {
|
|
65
84
|
constructor(id: number);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var $=import.meta.require;import{db as Z,sqlDateTime as H}from"@stacksjs/database";function j(f){return f.toLowerCase().trim().replace(/[^\w\s-]+/g,"").replace(/\s+/g,"-").replace(/-+/g,"-")}function O(f){return{name:f.name,slug:f.slug??j(f.name),description:f.description??null,status:"active",is_public:f.isPublic===!1?0:1,double_opt_in:f.doubleOptIn===!1?0:1,subscriber_count:0,active_count:0,unsubscribed_count:0,bounced_count:0}}var P={async create(f){let{EmailList:r}=await import("@stacksjs/orm");return r.create(O(f))},async find(f){let{EmailList:r}=await import("@stacksjs/orm");if(typeof f==="number")return r.find(f);return r.where("slug",f).first()},async all(){let{EmailList:f}=await import("@stacksjs/orm");return f.where("status","active").get()},async archive(f){let r=await P.find(f);if(!r)throw Error(`[newsletter] List '${String(f)}' not found`);return r.update({status:"archived"})}};async function W(f){if(f.emailListId)return f.emailListId;if(f.emailListSlug){let r=await P.find(f.emailListSlug);if(!r)throw Error(`[newsletter] List '${f.emailListSlug}' not found`);return r.id}throw Error("[newsletter] Campaign requires emailListId or emailListSlug")}class B extends Error{constructor(f){super(`[newsletter] Campaign ${f} changed before delivery could be queued`);this.name="CampaignStateConflictError"}}function _(f,r,o){if(typeof f?.get==="function"){let n=f.get(r);if(n!==void 0)return n;return f.get(o)}return f?.[r]??f?.[o]}function G(f){let r=_(f,"scheduled_at","scheduledAt"),o=_(f,"updated_at","updatedAt");return{status:String(_(f,"status","status")||""),scheduledAt:r===null||r===void 0||r===""?null:String(r),updatedAt:o===null||o===void 0||o===""?null:String(o)}}function E(f,r,o,n){return`newsletter:campaign:${f}:${r}:${n||"now"}:${o}`}function X(f){return["draft","scheduled","paused","failed"].includes(String(f||"").toLowerCase())}function V(f){if(Array.isArray(f))return f.reduce((n,w)=>n+V(w),0);if(typeof f==="number"||typeof f==="bigint")return Number(f);let r=f,o=r?.changes??r?.affectedRows??r?.numAffectedRows??r?.numUpdatedRows;if(typeof o==="object"&&o!==null)return Number(o.changes||0);return Number(o||0)}function v(f,r){return r===null?f.whereNull("scheduled_at"):f.where("scheduled_at","=",r)}function y(f,r){return r===null?f.whereNull("updated_at"):f.where("updated_at","=",r)}async function J(f,r,o){let w=await y(v(Z.updateTable("campaigns").set({status:o.status,scheduled_at:o.scheduledAt,updated_at:o.updatedAt}).where("id","=",f).where("status","=",r.status),r.scheduledAt),r.updatedAt).execute();if(V(w)!==1)throw new B(f)}async function I(f,r,o){await v(Z.updateTable("campaigns").set({status:o.status,scheduled_at:o.scheduledAt,updated_at:H()}).where("id","=",f).where("status","=",r.status).where("updated_at","=",r.updatedAt),r.scheduledAt).execute()}async function Y(f,r,o,n,w,U=0){let{job:x}=await import("@stacksjs/queue"),N=w?"scheduled":"immediate";try{await x("SendCampaign",{campaignId:f,chunkSize:n.chunkSize??50,dryRun:n.dryRun??!1,...w?{scheduledAt:w}:{}}).onQueue("campaigns").delay(U).withIdempotencyKey(E(f,N,r.attemptId,w)).dispatch()}catch(z){try{await I(f,r,o)}catch(T){throw AggregateError([z,T],`[newsletter] Campaign ${f} could not be dispatched or restored`)}throw z}}function g(f,r){if(!r)return!0;if(String(f.status||"")!=="scheduled")return!1;let o=f.scheduled_at??f.scheduledAt,n=new Date(String(o||"")).getTime(),w=new Date(r).getTime();return Number.isFinite(n)&&Number.isFinite(w)&&n===w}function b(f,r){return{name:f.name,description:f.description??null,type:"email",status:f.scheduledAt?"scheduled":"draft",subject:f.subject,template:f.template,text:f.text??null,from_name:f.fromName??null,from_address:f.fromAddress??null,email_list_id:r,scheduled_at:f.scheduledAt??null,sent_count:0}}function L(f,r){let o={};if(f.name!==void 0)o.name=f.name;if(f.description!==void 0)o.description=f.description;if(f.subject!==void 0)o.subject=f.subject;if(f.template!==void 0)o.template=f.template;if(f.text!==void 0)o.text=f.text;if(f.fromName!==void 0)o.from_name=f.fromName;if(f.fromAddress!==void 0)o.from_address=f.fromAddress;if(r!==void 0)o.email_list_id=r;if(f.scheduledAt!==void 0)o.scheduled_at=f.scheduledAt||null,o.status=f.scheduledAt?"scheduled":"draft";return o}var Q={async create(f){let{Campaign:r}=await import("@stacksjs/orm"),o=await W(f);return r.create(b(f,o))},async find(f){let{Campaign:r}=await import("@stacksjs/orm");return r.find(f)},async update(f,r){let o=await Q.find(f);if(!o)throw Error(`[newsletter] Campaign ${f} not found`);if(o.status!=="draft"&&o.status!=="scheduled")throw Error(`[newsletter] Cannot edit campaign in status '${o.status}'`);let w=r.emailListId!==void 0||r.emailListSlug!==void 0?await W(r):void 0;return o.update(L(r,w))},async sendNow(f,r={}){let o=await Q.find(f);if(!o)throw Error(`[newsletter] Campaign ${f} not found`);if(!X(o.status))throw Error(`[newsletter] Campaign ${f} in status '${o.status}' cannot be queued`);let n=G(o),w={status:"sending",scheduledAt:n.scheduledAt,updatedAt:H(),attemptId:crypto.randomUUID()};return await J(f,n,w),await Y(f,w,n,r),{ok:!0,campaignId:f}},async schedule(f,r,o={}){let n=await Q.find(f);if(!n)throw Error(`[newsletter] Campaign ${f} not found`);if(!X(n.status))throw Error(`[newsletter] Campaign ${f} in status '${n.status}' cannot be scheduled`);let w=r instanceof Date?r:new Date(r);if(!Number.isFinite(w.getTime()))throw Error("[newsletter] scheduledAt must be a valid date");let U=Math.max(0,Math.floor((w.getTime()-Date.now())/1000)),x=w.toISOString(),N=G(n),z={status:U===0?"sending":"scheduled",scheduledAt:x,updatedAt:H(),attemptId:crypto.randomUUID()};return await J(f,N,z),await Y(f,z,N,o,U>0?x:void 0,U),{ok:!0,campaignId:f,scheduledAt:x}},async cancel(f){let r=await Q.find(f);if(!r)throw Error(`[newsletter] Campaign ${f} not found`);if(r.status==="sent")throw Error(`[newsletter] Campaign ${f} already sent. It cannot be cancelled`);let o=G(r),n={status:"cancelled",scheduledAt:o.scheduledAt,updatedAt:H(),attemptId:crypto.randomUUID()};return await J(f,o,n),Q.find(f)}};function k(f){let r=2166136261;for(let o=0;o<f.length;o++)r^=f.charCodeAt(o),r=Math.imul(r,16777619);return r>>>0}function u(f,r){if(r.length===0)throw Error("At least one delivery variant is required");let o=r.reduce((U,x)=>U+x.allocation,0);if(o<=0)throw Error("Delivery variant allocation must be greater than zero");let n=k(f)%o,w=0;for(let U of r){if(!Number.isFinite(U.allocation)||U.allocation<0)throw Error("Delivery variant allocation must be a non-negative number");if(w+=U.allocation,n<w)return U}return r[r.length-1]}function c(f){return["delivery",f.teamId,f.campaignId,f.channel,f.recipient.trim().toLowerCase(),f.occurrence||"once"].join(":")}function d(f){let r=f.find((o)=>{if(o.limit===null)return!1;return o.used+(o.requested??1)>o.limit});if(r)throw Error(`Usage limit exceeded for ${r.meter}`)}function s(f,r=""){let o=r.toLowerCase();if(["suppressed","unsubscribe","complaint","stop"].some((n)=>o.includes(n)))return"suppressed";if(Number(f)===429||o.includes("rate"))return"rate_limited";if(Number(f)>=500||["timeout","temporary","unavailable"].some((n)=>o.includes(n)))return"transient";return"permanent"}function p(f){if(!f.url||!/^https?:\/\//i.test(f.url))throw Error("[newsletter] List-Unsubscribe URL must be absolute (http/https)");let r=[];if(f.mailto)r.push(`<mailto:${f.mailto}>`);return r.push(`<${f.url}>`),{"List-Unsubscribe":r.join(", "),"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}async function S(f){if(f==null){let o=await P.find("default");if(!o)o=await P.create({name:"Default",slug:"default"});return o.id}let r=await P.find(f);if(!r)throw Error(`[newsletter] List '${String(f)}' not found`);return r.id}async function M(f,r={}){if(!f||!f.includes("@"))throw Error("[newsletter] subscribe() requires a valid email address");let{Subscriber:o,EmailListSubscriber:n}=await import("@stacksjs/orm"),w=await S(r.list),U=r.source??"api",x=await o.where("email",f).first();if(!x)x=await o.create({email:f,status:"subscribed",source:U});let N=await n.where("subscriber_id",x.id).where("email_list_id",w).first();if(N){if(N.status==="unsubscribed")await N.update({status:"subscribed",unsubscribed_at:null});return{created:!1,email:f,listId:w,token:N.uuid}}let z=await n.create({subscriber_id:x.id,email_list_id:w,status:"subscribed",source:U});return{created:!0,email:f,listId:w,token:z.uuid}}async function D(f){if(!f)return{ok:!1};let{EmailListSubscriber:r,Subscriber:o}=await import("@stacksjs/orm"),n=await r.where("uuid",f).first();if(!n)return{ok:!1};if(n.status==="unsubscribed")return{ok:!0,alreadyUnsubscribed:!0,email:(await o.find(n.subscriber_id))?.email,listId:n.email_list_id};return await n.update({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}),{ok:!0,email:(await o.find(n.subscriber_id))?.email,listId:n.email_list_id}}async function R(f){let{Subscriber:r,EmailListSubscriber:o}=await import("@stacksjs/orm"),n=await r.where("email",f).first();if(!n)return 0;let w=await o.where("subscriber_id",n.id).where("status","subscribed").get();for(let U of w)await U.update({status:"unsubscribed",unsubscribed_at:new Date().toISOString()});return w.length}var wf={lists:P,campaigns:Q,subscribe:M,unsubscribe:D,unsubscribeAll:R};class F extends Error{code;constructor(f,r){let o={unavailable:`Usage meter unavailable for ${r}`,invalid:`Usage meter state is invalid for ${r}`,exceeded:`Usage limit exceeded for ${r}`,contention:`Usage reservation contention for ${r}`};super(o[f]);this.code=f;this.name="UsageQuotaError"}}function C(f){return Number.isFinite(f)&&f>0}function q(f,r){return f.meter===r&&Number.isFinite(f.used)&&f.used>=0&&(f.limit===null||Number.isFinite(f.limit)&&f.limit>=0)}function K(f){return Number.isInteger(f)&&f>0?f:1}async function xf(f,r,o=1,n=5){if(!r||!C(o))throw new F("invalid",r||"unknown");for(let w=0;w<K(n);w++){let U=await f.read(r);if(!U)throw new F("unavailable",r);if(!q(U,r))throw new F("invalid",r);let x=U.used+o;if(U.limit!==null&&x>U.limit)throw new F("exceeded",r);if(await f.compareAndSet(U,x))return{meter:r,quantity:o,previousUsed:U.used,used:x,limit:U.limit}}throw new F("contention",r)}async function Ff(f,r,o=5){if(!r.meter||!C(r.quantity))throw new F("invalid",r.meter||"unknown");for(let n=0;n<K(o);n++){let w=await f.read(r.meter);if(!w)throw new F("unavailable",r.meter);if(!q(w,r.meter)||w.used<r.quantity)throw new F("invalid",r.meter);if(await f.compareAndSet(w,w.used-r.quantity))return}throw new F("contention",r.meter)}export{R as unsubscribeAll,D as unsubscribe,M as subscribe,g as shouldRunScheduledCampaign,xf as reserveUsage,Ff as releaseUsage,wf as newsletter,P as lists,O as emailListCreateData,c as deliveryIdempotencyKey,s as classifyDeliveryFailure,X as canQueueCampaignStatus,Q as campaigns,L as campaignUpdateData,G as campaignDeliverySnapshot,E as campaignDeliveryDispatchKey,b as campaignCreateData,p as buildUnsubscribeHeaders,d as assertUsageAvailable,u as allocateDeliveryVariant,F as UsageQuotaError,B as CampaignStateConflictError};
|
|
2
|
+
var o=import.meta.require;import{db as R,sqlDateTime as _}from"@stacksjs/database";function E(f){return f.toLowerCase().trim().replace(/[^\w\s-]+/g,"").replace(/\s+/g,"-").replace(/-+/g,"-")}function L(f){return{name:f.name,slug:f.slug??E(f.name),description:f.description??null,status:"active",is_public:f.isPublic===!1?0:1,double_opt_in:f.doubleOptIn===!1?0:1,subscriber_count:0,active_count:0,unsubscribed_count:0,bounced_count:0}}var Q={async create(f){let{EmailList:U}=await import("@stacksjs/orm");return U.create(L(f))},async find(f){let{EmailList:U}=await import("@stacksjs/orm");if(typeof f==="number")return U.find(f);return U.where("slug",f).first()},async all(){let{EmailList:f}=await import("@stacksjs/orm");return f.where("status","active").get()},async archive(f){let U=await Q.find(f);if(!U)throw Error(`[newsletter] List '${String(f)}' not found`);return U.update({status:"archived"})}};async function V(f){if(f.emailListId)return f.emailListId;if(f.emailListSlug){let U=await Q.find(f.emailListSlug);if(!U)throw Error(`[newsletter] List '${f.emailListSlug}' not found`);return U.id}throw Error("[newsletter] Campaign requires emailListId or emailListSlug")}class C extends Error{constructor(f){super(`[newsletter] Campaign ${f} changed before delivery could be queued`);this.name="CampaignStateConflictError"}}function W(f,U,w){if(typeof f?.get==="function"){let x=f.get(U);if(x!==void 0)return x;return f.get(w)}return f?.[U]??f?.[w]}function X(f){let U=W(f,"scheduled_at","scheduledAt"),w=W(f,"updated_at","updatedAt");return{status:String(W(f,"status","status")||""),scheduledAt:U===null||U===void 0||U===""?null:String(U),updatedAt:w===null||w===void 0||w===""?null:String(w)}}function b(f,U,w,x){return`newsletter:campaign:${f}:${U}:${x||"now"}:${w}`}function M(f){return["draft","scheduled","paused","failed"].includes(String(f||"").toLowerCase())}function q(f){if(Array.isArray(f))return f.reduce((x,n)=>x+q(n),0);if(typeof f==="number"||typeof f==="bigint")return Number(f);let U=f,w=U?.changes??U?.affectedRows??U?.numAffectedRows??U?.numUpdatedRows;if(typeof w==="object"&&w!==null)return Number(w.changes||0);return Number(w||0)}function K(f,U){return U===null?f.whereNull("scheduled_at"):f.where("scheduled_at","=",U)}function S(f,U){return U===null?f.whereNull("updated_at"):f.where("updated_at","=",U)}async function Y(f,U,w){let n=await S(K(R.updateTable("campaigns").set({status:w.status,scheduled_at:w.scheduledAt,updated_at:w.updatedAt}).where("id","=",f).where("status","=",U.status),U.scheduledAt),U.updatedAt).execute();if(q(n)!==1)throw new C(f)}async function k(f,U,w){await K(R.updateTable("campaigns").set({status:w.status,scheduled_at:w.scheduledAt,updated_at:_()}).where("id","=",f).where("status","=",U.status).where("updated_at","=",U.updatedAt),U.scheduledAt).execute()}async function D(f,U,w,x,n,F=0){let{job:N}=await import("@stacksjs/queue"),r=n?"scheduled":"immediate";try{await N("SendCampaign",{campaignId:f,chunkSize:x.chunkSize??50,dryRun:x.dryRun??!1,...n?{scheduledAt:n}:{}}).onQueue("campaigns").delay(F).withIdempotencyKey(b(f,r,U.attemptId,n)).dispatch()}catch(P){try{await k(f,U,w)}catch($){throw AggregateError([P,$],`[newsletter] Campaign ${f} could not be dispatched or restored`)}throw P}}function a(f,U){if(!U)return!0;if(String(f.status||"")!=="scheduled")return!1;let w=f.scheduled_at??f.scheduledAt,x=new Date(String(w||"")).getTime(),n=new Date(U).getTime();return Number.isFinite(x)&&Number.isFinite(n)&&x===n}function A(f,U){return{name:f.name,description:f.description??null,type:"email",status:f.scheduledAt?"scheduled":"draft",subject:f.subject,template:f.template,text:f.text??null,from_name:f.fromName??null,from_address:f.fromAddress??null,email_list_id:U,scheduled_at:f.scheduledAt??null,sent_count:0}}function g(f,U){let w={};if(f.name!==void 0)w.name=f.name;if(f.description!==void 0)w.description=f.description;if(f.subject!==void 0)w.subject=f.subject;if(f.template!==void 0)w.template=f.template;if(f.text!==void 0)w.text=f.text;if(f.fromName!==void 0)w.from_name=f.fromName;if(f.fromAddress!==void 0)w.from_address=f.fromAddress;if(U!==void 0)w.email_list_id=U;if(f.scheduledAt!==void 0)w.scheduled_at=f.scheduledAt||null,w.status=f.scheduledAt?"scheduled":"draft";return w}var H={async create(f){let{Campaign:U}=await import("@stacksjs/orm"),w=await V(f);return U.create(A(f,w))},async find(f){let{Campaign:U}=await import("@stacksjs/orm");return U.find(f)},async update(f,U){let w=await H.find(f);if(!w)throw Error(`[newsletter] Campaign ${f} not found`);if(w.status!=="draft"&&w.status!=="scheduled")throw Error(`[newsletter] Cannot edit campaign in status '${w.status}'`);let n=U.emailListId!==void 0||U.emailListSlug!==void 0?await V(U):void 0;return w.update(g(U,n))},async sendNow(f,U={}){let w=await H.find(f);if(!w)throw Error(`[newsletter] Campaign ${f} not found`);if(!M(w.status))throw Error(`[newsletter] Campaign ${f} in status '${w.status}' cannot be queued`);let x=X(w),n={status:"sending",scheduledAt:x.scheduledAt,updatedAt:_(),attemptId:crypto.randomUUID()};return await Y(f,x,n),await D(f,n,x,U),{ok:!0,campaignId:f}},async schedule(f,U,w={}){let x=await H.find(f);if(!x)throw Error(`[newsletter] Campaign ${f} not found`);if(!M(x.status))throw Error(`[newsletter] Campaign ${f} in status '${x.status}' cannot be scheduled`);let n=U instanceof Date?U:new Date(U);if(!Number.isFinite(n.getTime()))throw Error("[newsletter] scheduledAt must be a valid date");let F=Math.max(0,Math.floor((n.getTime()-Date.now())/1000)),N=n.toISOString(),r=X(x),P={status:F===0?"sending":"scheduled",scheduledAt:N,updatedAt:_(),attemptId:crypto.randomUUID()};return await Y(f,r,P),await D(f,P,r,w,F>0?N:void 0,F),{ok:!0,campaignId:f,scheduledAt:N}},async cancel(f){let U=await H.find(f);if(!U)throw Error(`[newsletter] Campaign ${f} not found`);if(U.status==="sent")throw Error(`[newsletter] Campaign ${f} already sent. It cannot be cancelled`);let w=X(U),x={status:"cancelled",scheduledAt:w.scheduledAt,updatedAt:_(),attemptId:crypto.randomUUID()};return await Y(f,w,x),H.find(f)}};function m(f){let U=2166136261;for(let w=0;w<f.length;w++)U^=f.charCodeAt(w),U=Math.imul(U,16777619);return U>>>0}function Uf(f,U){if(U.length===0)throw Error("At least one delivery variant is required");let w=U.reduce((F,N)=>F+N.allocation,0);if(w<=0)throw Error("Delivery variant allocation must be greater than zero");let x=m(f)%w,n=0;for(let F of U){if(!Number.isFinite(F.allocation)||F.allocation<0)throw Error("Delivery variant allocation must be a non-negative number");if(n+=F.allocation,x<n)return F}return U[U.length-1]}function wf(f){return["delivery",f.teamId,f.campaignId,f.channel,f.recipient.trim().toLowerCase(),f.occurrence||"once"].join(":")}function xf(f){let U=f.find((w)=>{if(w.limit===null)return!1;return w.used+(w.requested??1)>w.limit});if(U)throw Error(`Usage limit exceeded for ${U.meter}`)}function nf(f,U=""){let w=U.toLowerCase();if(["suppressed","unsubscribe","complaint","stop"].some((x)=>w.includes(x)))return"suppressed";if(Number(f)===429||w.includes("rate"))return"rate_limited";if(Number(f)>=500||["timeout","temporary","unavailable"].some((x)=>w.includes(x)))return"transient";return"permanent"}function Nf(f){if(!f.url||!/^https?:\/\//i.test(f.url))throw Error("[newsletter] List-Unsubscribe URL must be absolute (http/https)");let U=[];if(f.mailto)U.push(`<mailto:${f.mailto}>`);return U.push(`<${f.url}>`),{"List-Unsubscribe":U.join(", "),"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}async function h(f){if(f==null){let w=await Q.find("default");if(!w)w=await Q.create({name:"Default",slug:"default"});return w.id}let U=await Q.find(f);if(!U)throw Error(`[newsletter] List '${String(f)}' not found`);return U.id}async function G(f,U={}){if(!f||!f.includes("@"))throw Error("[newsletter] subscribe() requires a valid email address");let{Subscriber:w,EmailListSubscriber:x}=await import("@stacksjs/orm"),n=await h(U.list),F=U.source??"api",N=await w.where("email",f).first();if(!N)N=await w.create({email:f,status:"subscribed",source:F});let r=await x.where("subscriber_id",N.id).where("email_list_id",n).first();if(r){if(r.status==="unsubscribed")await r.update({status:"subscribed",unsubscribed_at:null});return{created:!1,email:f,listId:n,token:r.uuid}}let P=await x.create({subscriber_id:N.id,email_list_id:n,status:"subscribed",source:F});return{created:!0,email:f,listId:n,token:P.uuid}}async function T(f){if(!f)return{ok:!1};let{EmailListSubscriber:U,Subscriber:w}=await import("@stacksjs/orm"),x=await U.where("uuid",f).first();if(!x)return{ok:!1};if(x.status==="unsubscribed")return{ok:!0,alreadyUnsubscribed:!0,email:(await w.find(x.subscriber_id))?.email,listId:x.email_list_id};return await x.update({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}),{ok:!0,email:(await w.find(x.subscriber_id))?.email,listId:x.email_list_id}}async function O(f){let{Subscriber:U,EmailListSubscriber:w}=await import("@stacksjs/orm"),x=await U.where("email",f).first();if(!x)return 0;let n=await w.where("subscriber_id",x.id).where("status","subscribed").get();for(let F of n)await F.update({status:"unsubscribed",unsubscribed_at:new Date().toISOString()});return n.length}var Hf={lists:Q,campaigns:H,subscribe:G,unsubscribe:T,unsubscribeAll:O};function J(f){return Boolean(f)&&f.includes("@")&&!f.startsWith("@")&&!f.endsWith("@")}function u(f,U){if(!J(f))return"skip-invalid";if(U.duplicate)return"skip-duplicate";if(U.suppressed)return"skip-suppressed";if(U.listStatus==="unsubscribed")return"skip-unsubscribed";return U.listStatus==="subscribed"?"existing":"add"}async function Jf(f,U,w={}){let{EmailListSubscriber:x,Subscriber:n}=await import("@stacksjs/orm"),F={added:0,existing:0,unsubscribed:0,suppressed:0,invalid:0,skipped:{unsubscribed:[],suppressed:[],invalid:[]}},N=new Set;for(let r of U){let P=String(r.email??""),$=P.trim().toLowerCase(),Z=null;if(J($)&&!N.has($)){let B=await n.where("email",$).first();if(B)Z=(await x.where("subscriber_id",B.id).where("email_list_id",await e(f)).first())?.status??null}let y=u($,{duplicate:N.has($),suppressed:J($)?await l($):!1,listStatus:Z});if(J($))N.add($);switch(y){case"skip-invalid":F.invalid++,F.skipped.invalid.push(P);continue;case"skip-duplicate":continue;case"skip-suppressed":F.suppressed++,F.skipped.suppressed.push($);continue;case"skip-unsubscribed":F.unsubscribed++,F.skipped.unsubscribed.push($);continue;case"existing":F.existing++;continue;case"add":break}if((await G($,{list:f,source:r.source??w.source??"sync",upsert:!0})).created)F.added++;else F.existing++}return F}async function l(f){try{let{isSuppressed:U}=await import("@stacksjs/email");return await U(f)}catch{return!1}}async function e(f){if(typeof f==="number")return f;let{EmailList:U}=await import("@stacksjs/orm"),w=await U.where("slug",f).first();return Number(w?.id??0)}class z extends Error{code;constructor(f,U){let w={unavailable:`Usage meter unavailable for ${U}`,invalid:`Usage meter state is invalid for ${U}`,exceeded:`Usage limit exceeded for ${U}`,contention:`Usage reservation contention for ${U}`};super(w[f]);this.code=f;this.name="UsageQuotaError"}}function v(f){return Number.isFinite(f)&&f>0}function j(f,U){return f.meter===U&&Number.isFinite(f.used)&&f.used>=0&&(f.limit===null||Number.isFinite(f.limit)&&f.limit>=0)}function I(f){return Number.isInteger(f)&&f>0?f:1}async function Xf(f,U,w=1,x=5){if(!U||!v(w))throw new z("invalid",U||"unknown");for(let n=0;n<I(x);n++){let F=await f.read(U);if(!F)throw new z("unavailable",U);if(!j(F,U))throw new z("invalid",U);let N=F.used+w;if(F.limit!==null&&N>F.limit)throw new z("exceeded",U);if(await f.compareAndSet(F,N))return{meter:U,quantity:w,previousUsed:F.used,used:N,limit:F.limit}}throw new z("contention",U)}async function Yf(f,U,w=5){if(!U.meter||!v(U.quantity))throw new z("invalid",U.meter||"unknown");for(let x=0;x<I(w);x++){let n=await f.read(U.meter);if(!n)throw new z("unavailable",U.meter);if(!j(n,U.meter)||n.used<U.quantity)throw new z("invalid",U.meter);if(await f.compareAndSet(n,n.used-U.quantity))return}throw new z("contention",U.meter)}export{O as unsubscribeAll,T as unsubscribe,Jf as syncListSubscribers,u as syncDecision,G as subscribe,a as shouldRunScheduledCampaign,Xf as reserveUsage,Yf as releaseUsage,Hf as newsletter,J as looksLikeEmail,Q as lists,L as emailListCreateData,wf as deliveryIdempotencyKey,nf as classifyDeliveryFailure,M as canQueueCampaignStatus,H as campaigns,g as campaignUpdateData,X as campaignDeliverySnapshot,b as campaignDeliveryDispatchKey,A as campaignCreateData,Nf as buildUnsubscribeHeaders,xf as assertUsageAvailable,Uf as allocateDeliveryVariant,z as UsageQuotaError,C as CampaignStateConflictError};
|
package/dist/lists.d.ts
CHANGED
|
@@ -4,17 +4,41 @@ export declare function emailListCreateData(input: CreateListInput): Record<stri
|
|
|
4
4
|
* @defaultValue
|
|
5
5
|
* ```ts
|
|
6
6
|
* {
|
|
7
|
-
* create: (input: CreateListInput) => Promise<
|
|
8
|
-
* find: (idOrSlug: number | string) => Promise<
|
|
9
|
-
* all: () => Promise<
|
|
10
|
-
* archive: (idOrSlug: number | string) => Promise<
|
|
7
|
+
* create: (input: CreateListInput) => Promise<EmailListRow>,
|
|
8
|
+
* find: (idOrSlug: number | string) => Promise<EmailListRow | undefined>,
|
|
9
|
+
* all: () => Promise<EmailListRow[]>,
|
|
10
|
+
* archive: (idOrSlug: number | string) => Promise<unknown>
|
|
11
11
|
* }
|
|
12
12
|
* ```
|
|
13
13
|
*/
|
|
14
14
|
export declare const lists: {
|
|
15
|
-
create: (input: CreateListInput) => Promise<
|
|
15
|
+
create: (input: CreateListInput) => Promise<EmailListRow>;
|
|
16
16
|
/** Look up by slug first, then by id — slugs are the public-facing handle. */
|
|
17
|
-
find: (idOrSlug: number | string) => Promise<
|
|
18
|
-
all: () => Promise<
|
|
19
|
-
archive: (idOrSlug: number | string) => Promise<
|
|
17
|
+
find: (idOrSlug: number | string) => Promise<EmailListRow | undefined>;
|
|
18
|
+
all: () => Promise<EmailListRow[]>;
|
|
19
|
+
archive: (idOrSlug: number | string) => Promise<unknown>
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* A list row as callers use it.
|
|
23
|
+
*
|
|
24
|
+
* The ORM models are reached through `await import(...) as any` (they are
|
|
25
|
+
* generated, and importing them eagerly would make this package depend on the
|
|
26
|
+
* app's model graph), so nothing here has a return type to infer FROM - and
|
|
27
|
+
* dtsx wrote `Promise<void>` into the published types. Every consumer of
|
|
28
|
+
* `lists.find()` therefore got a value it could not read: `if (!list)` did not
|
|
29
|
+
* even compile. Naming the shape is enough to fix that, and it is the shape
|
|
30
|
+
* `email_lists` actually has.
|
|
31
|
+
*/
|
|
32
|
+
export declare interface EmailListRow {
|
|
33
|
+
id: number
|
|
34
|
+
uuid?: string
|
|
35
|
+
name: string
|
|
36
|
+
slug: string
|
|
37
|
+
description?: string | null
|
|
38
|
+
status?: string
|
|
39
|
+
subscriber_count?: number
|
|
40
|
+
unsubscribed_count?: number
|
|
41
|
+
bounced_count?: number
|
|
42
|
+
update: (data: Record<string, unknown>) => Promise<unknown>
|
|
43
|
+
[key: string]: unknown
|
|
44
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare function looksLikeEmail(value: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* The decision itself, separated from the reading and writing.
|
|
4
|
+
*
|
|
5
|
+
* All the judgement a sync makes lives here: an address that opted out stays
|
|
6
|
+
* out, a suppressed address is never added back, a repeated address counts
|
|
7
|
+
* once, and anything that is not an address at all is reported rather than
|
|
8
|
+
* attempted.
|
|
9
|
+
*/
|
|
10
|
+
export declare function syncDecision(email: string, state: SyncContactState): SyncDecision;
|
|
11
|
+
export declare function syncListSubscribers(list: string | number, contacts: readonly SyncContact[], options?: { source?: string }): Promise<SyncListResult>;
|
|
12
|
+
/**
|
|
13
|
+
* Whether an address is suppressed.
|
|
14
|
+
*
|
|
15
|
+
* `@stacksjs/email` is imported lazily and its absence is not fatal: this
|
|
16
|
+
* package does not depend on it (nothing here sends), and an app running the
|
|
17
|
+
* newsletter tables without the email package should still be able to sync a
|
|
18
|
+
* list - it simply has no suppression list to consult.
|
|
19
|
+
*/
|
|
20
|
+
declare function suppressed(email: string): Promise<boolean>;
|
|
21
|
+
/**
|
|
22
|
+
* Putting an audience you already have onto a list.
|
|
23
|
+
*
|
|
24
|
+
* `subscribe()` is the right call when a person asks: it even flips a
|
|
25
|
+
* previously-unsubscribed row back on, because someone signing up again means
|
|
26
|
+
* they want back in.
|
|
27
|
+
*
|
|
28
|
+
* A bulk sync is the opposite situation. The input is "every contact in my
|
|
29
|
+
* database", not a request from any of them, so the same behaviour would
|
|
30
|
+
* silently resurrect everyone who had opted out - rude, and in most
|
|
31
|
+
* jurisdictions unlawful. This never revives an unsubscribed row and never
|
|
32
|
+
* adds a suppressed address (a bounce or a spam complaint), and it reports
|
|
33
|
+
* what it skipped so the caller can say so out loud.
|
|
34
|
+
*
|
|
35
|
+
* Idempotent: running it after every enrolment change is the intended use.
|
|
36
|
+
*/
|
|
37
|
+
export declare interface SyncContact {
|
|
38
|
+
email: string
|
|
39
|
+
source?: string
|
|
40
|
+
}
|
|
41
|
+
export declare interface SyncListResult {
|
|
42
|
+
added: number
|
|
43
|
+
existing: number
|
|
44
|
+
unsubscribed: number
|
|
45
|
+
suppressed: number
|
|
46
|
+
invalid: number
|
|
47
|
+
skipped: { unsubscribed: string[], suppressed: string[], invalid: string[] }
|
|
48
|
+
}
|
|
49
|
+
export declare interface SyncContactState {
|
|
50
|
+
duplicate: boolean
|
|
51
|
+
suppressed: boolean
|
|
52
|
+
listStatus: string | null
|
|
53
|
+
}
|
|
54
|
+
/** What a sync should do with one contact. */
|
|
55
|
+
export type SyncDecision = 'add' | 'existing' | 'skip-unsubscribed' | 'skip-suppressed' | 'skip-invalid' | 'skip-duplicate';
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/newsletter",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.11",
|
|
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": [
|
|
@@ -61,15 +61,15 @@
|
|
|
61
61
|
"prepublishOnly": "bun run build"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@stacksjs/database": "0.72.
|
|
65
|
-
"@stacksjs/orm": "0.72.
|
|
66
|
-
"@stacksjs/queue": "0.72.
|
|
64
|
+
"@stacksjs/database": "0.72.11",
|
|
65
|
+
"@stacksjs/orm": "0.72.11",
|
|
66
|
+
"@stacksjs/queue": "0.72.11"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@stacksjs/cli": "0.72.
|
|
70
|
-
"@stacksjs/config": "0.72.
|
|
69
|
+
"@stacksjs/cli": "0.72.11",
|
|
70
|
+
"@stacksjs/config": "0.72.11",
|
|
71
71
|
"better-dx": "^0.2.23",
|
|
72
|
-
"@stacksjs/error-handling": "0.72.
|
|
73
|
-
"@stacksjs/types": "0.72.
|
|
72
|
+
"@stacksjs/error-handling": "0.72.11",
|
|
73
|
+
"@stacksjs/types": "0.72.11"
|
|
74
74
|
}
|
|
75
75
|
}
|