@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.
@@ -22,9 +22,9 @@ export declare function campaignUpdateData(patch: Partial<CreateCampaignInput>,
22
22
  * @defaultValue
23
23
  * ```ts
24
24
  * {
25
- * create: (input: CreateCampaignInput) => Promise<void>,
26
- * find: (id: number) => Promise<void>,
27
- * update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<void>,
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<void>;
36
- find: (id: number) => Promise<void>;
37
- update: (id: number, patch: Partial<CreateCampaignInput>) => Promise<void>;
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
@@ -4,5 +4,6 @@ export * from './headers';
4
4
  export * from './lists';
5
5
  export * from './newsletter';
6
6
  export * from './subscriptions';
7
+ export * from './sync';
7
8
  export * from './types';
8
9
  export * from './usage';
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<void>,
8
- * find: (idOrSlug: number | string) => Promise<void>,
9
- * all: () => Promise<void>,
10
- * archive: (idOrSlug: number | string) => Promise<void>
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<void>;
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<void>;
18
- all: () => Promise<void>;
19
- archive: (idOrSlug: number | string) => Promise<void>
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.9",
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.9",
65
- "@stacksjs/orm": "0.72.9",
66
- "@stacksjs/queue": "0.72.9"
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.9",
70
- "@stacksjs/config": "0.72.9",
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.9",
73
- "@stacksjs/types": "0.72.9"
72
+ "@stacksjs/error-handling": "0.72.11",
73
+ "@stacksjs/types": "0.72.11"
74
74
  }
75
75
  }