@stacksjs/newsletter 0.74.46 → 0.74.47

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.
@@ -13,6 +13,41 @@ declare function resolveListId(input: CreateCampaignInput): Promise<number>;
13
13
  export declare function campaignDeliverySnapshot(campaign: CampaignLike | null | undefined): CampaignDeliverySnapshot;
14
14
  export declare function campaignDeliveryDispatchKey(id: number, mode: 'immediate' | 'scheduled', attempt: string, scheduledAt?: string): string;
15
15
  export declare function canQueueCampaignStatus(status: unknown): boolean;
16
+ /**
17
+ * Move a campaign from the state the caller read to `target`, or refuse.
18
+ *
19
+ * A compare-and-set against the caller's snapshot: the write only lands if
20
+ * status, scheduled_at and updated_at still hold what the caller read.
21
+ *
22
+ * On PostgreSQL and SQLite that is one atomic UPDATE whose affected-row count
23
+ * is the verdict, because both count the rows an UPDATE MATCHED. MySQL counts
24
+ * the rows it CHANGED, so there the same count cannot carry the decision: a
25
+ * transition whose target is the state the campaign is already in reported 0.
26
+ * `sqlDateTime` has second precision, so `updated_at` does not save it, and
27
+ * cancelling a cancelled campaign - a double click - threw
28
+ * CampaignStateConflictError for a campaign nobody else had touched
29
+ * (stacksjs/stacks#2639).
30
+ *
31
+ * The obvious repair is the dangerous one. Re-reading after a zero and
32
+ * accepting "the row already holds the target" cannot tell our no-op from a
33
+ * concurrent caller that got there first: two `sendNow()` calls in the same
34
+ * second both target `sending`, the loser's re-read finds exactly its target,
35
+ * and it would report success and dispatch a second SendCampaignJob. The
36
+ * campaign would go out twice.
37
+ *
38
+ * So on MySQL the comparison moves in front of the write, under a row lock,
39
+ * and is made by the database with the same predicate the UPDATE uses, which
40
+ * keeps the stored-format question out of JavaScript. A caller that loses a
41
+ * race waits for the winner, finds the snapshot no longer matches, and gets
42
+ * the conflict; with the row locked and matched, the UPDATE cannot miss, so
43
+ * its count is not read.
44
+ *
45
+ * The other dialects keep the single statement on purpose, not as a fallback.
46
+ * Opening a transaction there would make SQLite worse: its SELECT pins a read
47
+ * snapshot, and when a competing writer commits first the UPDATE fails to
48
+ * upgrade the transaction with a raw SQLITE_BUSY instead of re-checking its
49
+ * WHERE and reporting the conflict the caller can act on.
50
+ */
16
51
  declare function transitionCampaignDelivery(id: number, expected: CampaignDeliverySnapshot, target: CampaignDeliveryTarget): Promise<void>;
17
52
  declare function dispatchCampaign(id: number, target: CampaignDeliveryTarget, previous: CampaignDeliverySnapshot, options: SendCampaignOptions, scheduledAt?: string, delaySeconds?: number): Promise<void>;
18
53
  export declare function shouldRunScheduledCampaign(campaign: ScheduledCampaignState, expectedScheduledAt?: string): boolean;
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- import{db as b,sqlDateTime as f}from"@stacksjs/database";import{db as p}from"@stacksjs/database";function N(e){return e.toLowerCase().trim().replace(/[^\w\s-]+/g,"").replace(/\s+/g,"-").replace(/-+/g,"-")}function P(e){return{name:e.name,slug:e.slug??N(e.name),description:e.description??null,status:"active",is_public:e.isPublic===!1?0:1,double_opt_in:e.doubleOptIn===!1?0:1,subscriber_count:0,active_count:0,unsubscribed_count:0,bounced_count:0}}var d={async create(e){let t=P(e);await p.insertInto("email_lists").values(t).execute();let n=await d.find(String(t.slug));if(!n)throw Error(`[newsletter] List '${String(t.slug)}' could not be read back after creation`);return n},async find(e){let t=p.selectFrom("email_lists").selectAll();return typeof e==="number"?await t.where("id","=",e).executeTakeFirst():await t.where("slug","=",e).executeTakeFirst()},async all(){return await p.selectFrom("email_lists").selectAll().where("status","=","active").execute()},async archive(e){let t=await d.find(e);if(!t)throw Error(`[newsletter] List '${String(e)}' not found`);return await p.updateTable("email_lists").set({status:"archived"}).where("id","=",Number(t.id)).execute()}};async function S(e){if(e.emailListId)return e.emailListId;if(e.emailListSlug){let t=await d.find(e.emailListSlug);if(!t)throw Error(`[newsletter] List '${e.emailListSlug}' not found`);return t.id}throw Error("[newsletter] Campaign requires emailListId or emailListSlug")}class I extends Error{constructor(e){super(`[newsletter] Campaign ${e} changed before delivery could be queued`);this.name="CampaignStateConflictError"}}function y(e,t,n){if(typeof e?.get==="function"){let r=e.get(t);if(r!==void 0)return r;return e.get(n)}return e?.[t]??e?.[n]}function x(e){let t=y(e,"scheduled_at","scheduledAt"),n=y(e,"updated_at","updatedAt");return{status:String(y(e,"status","status")||""),scheduledAt:t===null||t===void 0||t===""?null:String(t),updatedAt:n===null||n===void 0||n===""?null:String(n)}}function q(e,t,n,r){return`newsletter:campaign:${e}:${t}:${r||"now"}:${n}`}function k(e){return["draft","scheduled","paused","failed"].includes(String(e||"").toLowerCase())}function D(e){if(Array.isArray(e))return e.reduce((r,s)=>r+D(s),0);if(typeof e==="number"||typeof e==="bigint")return Number(e);let t=e,n=t?.changes??t?.affectedRows??t?.numAffectedRows??t?.numUpdatedRows;if(typeof n==="object"&&n!==null)return Number(n.changes||0);return Number(n||0)}function L(e,t){return t===null?e.whereNull("scheduled_at"):e.where("scheduled_at","=",t)}function j(e,t){return t===null?e.whereNull("updated_at"):e.where("updated_at","=",t)}async function C(e,t,n){let s=await j(L(b.updateTable("campaigns").set({status:n.status,scheduled_at:n.scheduledAt,updated_at:n.updatedAt}).where("id","=",e).where("status","=",t.status),t.scheduledAt),t.updatedAt).execute();if(D(s)!==1)throw new I(e)}async function O(e,t,n){await L(b.updateTable("campaigns").set({status:n.status,scheduled_at:n.scheduledAt,updated_at:f()}).where("id","=",e).where("status","=",t.status).where("updated_at","=",t.updatedAt),t.scheduledAt).execute()}async function A(e,t,n,r,s,i=0){let{job:a}=await import("@stacksjs/queue"),u=s?"scheduled":"immediate";try{await a("SendCampaignJob",{campaignId:e,chunkSize:r.chunkSize??50,dryRun:r.dryRun??!1,...s?{scheduledAt:s}:{}}).onQueue("campaigns").delay(i).withIdempotencyKey(q(e,u,t.attemptId,s)).dispatch()}catch(c){try{await O(e,t,n)}catch(h){throw AggregateError([c,h],`[newsletter] Campaign ${e} could not be dispatched or restored`)}throw c}}function ee(e,t){if(!t)return!0;if(String(e.status||"")!=="scheduled")return!1;let n=e.scheduled_at??e.scheduledAt,r=new Date(String(n||"")).getTime(),s=new Date(t).getTime();return Number.isFinite(r)&&Number.isFinite(s)&&r===s}function V(e,t){return{name:e.name,description:e.description??null,type:"email",status:e.scheduledAt?"scheduled":"draft",subject:e.subject,template:e.template,text:e.text??null,from_name:e.fromName??null,from_address:e.fromAddress??null,email_list_id:t,scheduled_at:e.scheduledAt??null,sent_count:0}}function Q(e,t){let n={};if(e.name!==void 0)n.name=e.name;if(e.description!==void 0)n.description=e.description;if(e.subject!==void 0)n.subject=e.subject;if(e.template!==void 0)n.template=e.template;if(e.text!==void 0)n.text=e.text;if(e.fromName!==void 0)n.from_name=e.fromName;if(e.fromAddress!==void 0)n.from_address=e.fromAddress;if(t!==void 0)n.email_list_id=t;if(e.scheduledAt!==void 0)n.scheduled_at=e.scheduledAt||null,n.status=e.scheduledAt?"scheduled":"draft";return n}var m={async create(e){let t=await S(e),n=V(e,t),r=crypto.randomUUID();await b.insertInto("campaigns").values({...n,uuid:r}).execute();let s=await b.selectFrom("campaigns").selectAll().where("uuid","=",r).executeTakeFirst();if(!s)throw Error("[newsletter] Campaign could not be read back after creation");return s},async find(e){return await b.selectFrom("campaigns").selectAll().where("id","=",e).executeTakeFirst()},async update(e,t){let n=await m.find(e);if(!n)throw Error(`[newsletter] Campaign ${e} not found`);if(n.status!=="draft"&&n.status!=="scheduled")throw Error(`[newsletter] Cannot edit campaign in status '${n.status}'`);let s=t.emailListId!==void 0||t.emailListSlug!==void 0?await S(t):void 0;return n.update(Q(t,s))},async sendNow(e,t={}){let n=await m.find(e);if(!n)throw Error(`[newsletter] Campaign ${e} not found`);if(!k(n.status))throw Error(`[newsletter] Campaign ${e} in status '${n.status}' cannot be queued`);let r=x(n),s={status:"sending",scheduledAt:r.scheduledAt,updatedAt:f(),attemptId:crypto.randomUUID()};return await C(e,r,s),await A(e,s,r,t),{ok:!0,campaignId:e}},async schedule(e,t,n={}){let r=await m.find(e);if(!r)throw Error(`[newsletter] Campaign ${e} not found`);if(!k(r.status))throw Error(`[newsletter] Campaign ${e} in status '${r.status}' cannot be scheduled`);let s=t instanceof Date?t:new Date(t);if(!Number.isFinite(s.getTime()))throw Error("[newsletter] scheduledAt must be a valid date");let i=Math.max(0,Math.floor((s.getTime()-Date.now())/1000)),a=s.toISOString(),u=x(r),c={status:i===0?"sending":"scheduled",scheduledAt:a,updatedAt:f(),attemptId:crypto.randomUUID()};return await C(e,u,c),await A(e,c,u,n,i>0?a:void 0,i),{ok:!0,campaignId:e,scheduledAt:a}},async cancel(e){let t=await m.find(e);if(!t)throw Error(`[newsletter] Campaign ${e} not found`);if(t.status==="sent")throw Error(`[newsletter] Campaign ${e} already sent. It cannot be cancelled`);let n=x(t),r={status:"cancelled",scheduledAt:n.scheduledAt,updatedAt:f(),attemptId:crypto.randomUUID()};return await C(e,n,r),m.find(e)}};function W(e){let t=2166136261;for(let n=0;n<e.length;n++)t^=e.charCodeAt(n),t=Math.imul(t,16777619);return t>>>0}function ne(e,t){if(t.length===0)throw Error("At least one delivery variant is required");let n=t.reduce((i,a)=>i+a.allocation,0);if(n<=0)throw Error("Delivery variant allocation must be greater than zero");let r=W(e)%n,s=0;for(let i of t){if(!Number.isFinite(i.allocation)||i.allocation<0)throw Error("Delivery variant allocation must be a non-negative number");if(s+=i.allocation,r<s)return i}return t[t.length-1]}function re(e){return["delivery",e.teamId,e.campaignId,e.channel,e.recipient.trim().toLowerCase(),e.occurrence||"once"].join(":")}function se(e){let t=e.find((n)=>{if(n.limit===null)return!1;return n.used+(n.requested??1)>n.limit});if(t)throw Error(`Usage limit exceeded for ${t.meter}`)}function ie(e,t=""){let n=t.toLowerCase();if(["suppressed","unsubscribe","complaint","stop"].some((r)=>n.includes(r)))return"suppressed";if(Number(e)===429||n.includes("rate"))return"rate_limited";if(Number(e)>=500||["timeout","temporary","unavailable"].some((r)=>n.includes(r)))return"transient";return"permanent"}function ue(e){if(!e.url||!/^https?:\/\//i.test(e.url))throw Error("[newsletter] List-Unsubscribe URL must be absolute (http/https)");let t=[];if(e.mailto)t.push(`<mailto:${e.mailto}>`);return t.push(`<${e.url}>`),{"List-Unsubscribe":t.join(", "),"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}import{db as o}from"@stacksjs/database";async function M(e){if(e==null){let n=await d.find("default");if(!n)n=await d.create({name:"Default",slug:"default"});return n.id}let t=await d.find(e);if(!t)throw Error(`[newsletter] List '${String(e)}' not found`);return t.id}async function g(e,t={}){if(!e||!e.includes("@"))throw Error("[newsletter] subscribe() requires a valid email address");let n=await M(t.list),r=t.source??"api",s=await o.selectFrom("subscribers").selectAll().where("email","=",e).executeTakeFirst();if(!s)await o.insertInto("subscribers").values({uuid:crypto.randomUUID(),email:e,status:"subscribed",source:r}).execute(),s=await o.selectFrom("subscribers").selectAll().where("email","=",e).executeTakeFirst();let i=await o.selectFrom("email_list_subscribers").selectAll().where("subscriber_id","=",Number(s.id)).where("email_list_id","=",n).executeTakeFirst();if(i){if(i.status==="unsubscribed")await o.updateTable("email_list_subscribers").set({status:"subscribed",unsubscribed_at:null}).where("id","=",Number(i.id)).execute();return{created:!1,email:e,listId:n,token:i.uuid}}let a=crypto.randomUUID();return await o.insertInto("email_list_subscribers").values({uuid:a,subscriber_id:Number(s.id),email_list_id:n,status:"subscribed",source:r}).execute(),{created:!0,email:e,listId:n,token:a}}async function U(e){if(!e)return{ok:!1};let t=await o.selectFrom("email_list_subscribers").selectAll().where("uuid","=",e).executeTakeFirst();if(!t)return{ok:!1};let n=async()=>(await o.selectFrom("subscribers").select(["email"]).where("id","=",Number(t.subscriber_id)).executeTakeFirst())?.email;if(t.status==="unsubscribed")return{ok:!0,alreadyUnsubscribed:!0,email:await n(),listId:t.email_list_id};return await o.updateTable("email_list_subscribers").set({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}).where("id","=",Number(t.id)).execute(),{ok:!0,email:await n(),listId:t.email_list_id}}async function R(e){let t=await o.selectFrom("subscribers").select(["id"]).where("email","=",e).executeTakeFirst();if(!t)return 0;let n=await o.selectFrom("email_list_subscribers").select(["id"]).where("subscriber_id","=",Number(t.id)).where("status","=","subscribed").execute();for(let r of n)await o.updateTable("email_list_subscribers").set({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}).where("id","=",Number(r.id)).execute();return n.length}var fe={lists:d,campaigns:m,subscribe:g,unsubscribe:U,unsubscribeAll:R};import{db as _}from"@stacksjs/database";function w(e){return Boolean(e)&&e.includes("@")&&!e.startsWith("@")&&!e.endsWith("@")}function z(e,t){if(!w(e))return"skip-invalid";if(t.duplicate)return"skip-duplicate";if(t.suppressed)return"skip-suppressed";if(t.listStatus==="unsubscribed")return"skip-unsubscribed";return t.listStatus==="subscribed"?"existing":"add"}async function ye(e,t,n={}){let r={added:0,existing:0,unsubscribed:0,suppressed:0,invalid:0,skipped:{unsubscribed:[],suppressed:[],invalid:[]}},s=new Set;for(let i of t){let a=String(i.email??""),u=a.trim().toLowerCase(),c=null;if(w(u)&&!s.has(u)){let v=await _.selectFrom("subscribers").select(["id"]).where("email","=",u).executeTakeFirst();if(v)c=(await _.selectFrom("email_list_subscribers").select(["status"]).where("subscriber_id","=",Number(v.id)).where("email_list_id","=",await B(e)).executeTakeFirst())?.status??null}let h=z(u,{duplicate:s.has(u),suppressed:w(u)?await H(u):!1,listStatus:c});if(w(u))s.add(u);switch(h){case"skip-invalid":r.invalid++,r.skipped.invalid.push(a);continue;case"skip-duplicate":continue;case"skip-suppressed":r.suppressed++,r.skipped.suppressed.push(u);continue;case"skip-unsubscribed":r.unsubscribed++,r.skipped.unsubscribed.push(u);continue;case"existing":r.existing++;continue;case"add":break}if((await g(u,{list:e,source:i.source??n.source??"sync",upsert:!0})).created)r.added++;else r.existing++}return r}async function H(e){try{let{isSuppressed:t}=await import("@stacksjs/email");return await t(e)}catch{return!1}}async function B(e){if(typeof e==="number")return e;let t=await _.selectFrom("email_lists").select(["id"]).where("slug","=",e).executeTakeFirst();return Number(t?.id??0)}class l extends Error{code;constructor(e,t){let n={unavailable:`Usage meter unavailable for ${t}`,invalid:`Usage meter state is invalid for ${t}`,exceeded:`Usage limit exceeded for ${t}`,contention:`Usage reservation contention for ${t}`};super(n[e]);this.code=e;this.name="UsageQuotaError"}}function F(e){return Number.isFinite(e)&&e>0}function T(e,t){return e.meter===t&&Number.isFinite(e.used)&&e.used>=0&&(e.limit===null||Number.isFinite(e.limit)&&e.limit>=0)}function E(e){return Number.isInteger(e)&&e>0?e:1}async function Ce(e,t,n=1,r=5){if(!t||!F(n))throw new l("invalid",t||"unknown");for(let s=0;s<E(r);s++){let i=await e.read(t);if(!i)throw new l("unavailable",t);if(!T(i,t))throw new l("invalid",t);let a=i.used+n;if(i.limit!==null&&a>i.limit)throw new l("exceeded",t);if(await e.compareAndSet(i,a))return{meter:t,quantity:n,previousUsed:i.used,used:a,limit:i.limit}}throw new l("contention",t)}async function _e(e,t,n=5){if(!t.meter||!F(t.quantity))throw new l("invalid",t.meter||"unknown");for(let r=0;r<E(n);r++){let s=await e.read(t.meter);if(!s)throw new l("unavailable",t.meter);if(!T(s,t.meter)||s.used<t.quantity)throw new l("invalid",t.meter);if(await e.compareAndSet(s,s.used-t.quantity))return}throw new l("contention",t.meter)}export{I as CampaignStateConflictError,l as UsageQuotaError,ne as allocateDeliveryVariant,se as assertUsageAvailable,ue as buildUnsubscribeHeaders,V as campaignCreateData,q as campaignDeliveryDispatchKey,x as campaignDeliverySnapshot,Q as campaignUpdateData,m as campaigns,k as canQueueCampaignStatus,ie as classifyDeliveryFailure,re as deliveryIdempotencyKey,P as emailListCreateData,d as lists,w as looksLikeEmail,fe as newsletter,_e as releaseUsage,Ce as reserveUsage,ee as shouldRunScheduledCampaign,g as subscribe,z as syncDecision,ye as syncListSubscribers,U as unsubscribe,R as unsubscribeAll};
2
+ import{db as p,getDatabaseDialect as O,mutationCount as V,parseSqlDateTime as j,sqlDateTime as b,sqlHelpers as M}from"@stacksjs/database";import{db as f,matchedRows as P}from"@stacksjs/database";function N(e){return e.toLowerCase().trim().replace(/[^\w\s-]+/g,"").replace(/\s+/g,"-").replace(/-+/g,"-")}function q(e){return{name:e.name,slug:e.slug??N(e.name),description:e.description??null,status:"active",is_public:e.isPublic===!1?0:1,double_opt_in:e.doubleOptIn===!1?0:1,subscriber_count:0,active_count:0,unsubscribed_count:0,bounced_count:0}}var d={async create(e){let t=q(e);await f.insertInto("email_lists").values(t).execute();let n=await d.find(String(t.slug));if(!n)throw Error(`[newsletter] List '${String(t.slug)}' could not be read back after creation`);return n},async find(e){let t=f.selectFrom("email_lists").selectAll();return typeof e==="number"?await t.where("id","=",e).executeTakeFirst():await t.where("slug","=",e).executeTakeFirst()},async all(){return await f.selectFrom("email_lists").selectAll().where("status","=","active").execute()},async archive(e){let t=await d.find(e);if(!t)throw Error(`[newsletter] List '${String(e)}' not found`);return(await P(f.updateTable("email_lists").set({status:"archived"}).where("id","=",Number(t.id)))).length}};async function S(e){if(e.emailListId)return e.emailListId;if(e.emailListSlug){let t=await d.find(e.emailListSlug);if(!t)throw Error(`[newsletter] List '${e.emailListSlug}' not found`);return t.id}throw Error("[newsletter] Campaign requires emailListId or emailListSlug")}class _ extends Error{constructor(e){super(`[newsletter] Campaign ${e} changed before delivery could be queued`);this.name="CampaignStateConflictError"}}function y(e,t,n){if(typeof e?.get==="function"){let s=e.get(t);if(s!==void 0)return s;return e.get(n)}return e?.[t]??e?.[n]}function D(e){if(e===null||e===void 0||e==="")return null;if(typeof e==="string")return e;let t=j(e);return t===null?null:b(t)}function x(e){return{status:String(y(e,"status","status")||""),scheduledAt:D(y(e,"scheduled_at","scheduledAt")),updatedAt:D(y(e,"updated_at","updatedAt"))}}function Q(e,t,n,s){return`newsletter:campaign:${e}:${t}:${s||"now"}:${n}`}function I(e){return["draft","scheduled","paused","failed"].includes(String(e||"").toLowerCase())}function L(e,t){return t===null?e.whereNull("scheduled_at"):e.where("scheduled_at","=",t)}function W(e,t){return t===null?e.whereNull("updated_at"):e.where("updated_at","=",t)}async function C(e,t,n){let s=(i)=>W(L(i.where("id","=",e).where("status","=",t.status),t.scheduledAt),t.updatedAt),r=(i)=>s(i.updateTable("campaigns").set({status:n.status,scheduled_at:n.scheduledAt,updated_at:n.updatedAt}));if(!M(O()).isMysql){if(V(await r(p).executeTakeFirst())!==1)throw new _(e);return}await p.transaction(async(i)=>{let a=i;if(!await s(a.selectFrom("campaigns")).select(["id"]).lockForUpdate().executeTakeFirst())throw new _(e);await r(a).execute()})}async function H(e,t,n){await L(p.updateTable("campaigns").set({status:n.status,scheduled_at:n.scheduledAt,updated_at:b()}).where("id","=",e).where("status","=",t.status).where("updated_at","=",t.updatedAt),t.scheduledAt).execute()}async function A(e,t,n,s,r,i=0){let{job:a}=await import("@stacksjs/queue"),u=r?"scheduled":"immediate";try{await a("SendCampaignJob",{campaignId:e,chunkSize:s.chunkSize??50,dryRun:s.dryRun??!1,...r?{scheduledAt:r}:{}}).onQueue("campaigns").delay(i).withIdempotencyKey(Q(e,u,t.attemptId,r)).dispatch()}catch(c){try{await H(e,t,n)}catch(h){throw AggregateError([c,h],`[newsletter] Campaign ${e} could not be dispatched or restored`)}throw c}}function ie(e,t){if(!t)return!0;if(String(e.status||"")!=="scheduled")return!1;let n=e.scheduled_at??e.scheduledAt,s=new Date(String(n||"")).getTime(),r=new Date(t).getTime();return Number.isFinite(s)&&Number.isFinite(r)&&s===r}function z(e,t){return{name:e.name,description:e.description??null,type:"email",status:e.scheduledAt?"scheduled":"draft",subject:e.subject,template:e.template,text:e.text??null,from_name:e.fromName??null,from_address:e.fromAddress??null,email_list_id:t,scheduled_at:e.scheduledAt??null,sent_count:0}}function B(e,t){let n={};if(e.name!==void 0)n.name=e.name;if(e.description!==void 0)n.description=e.description;if(e.subject!==void 0)n.subject=e.subject;if(e.template!==void 0)n.template=e.template;if(e.text!==void 0)n.text=e.text;if(e.fromName!==void 0)n.from_name=e.fromName;if(e.fromAddress!==void 0)n.from_address=e.fromAddress;if(t!==void 0)n.email_list_id=t;if(e.scheduledAt!==void 0)n.scheduled_at=e.scheduledAt||null,n.status=e.scheduledAt?"scheduled":"draft";return n}var m={async create(e){let t=await S(e),n=z(e,t),s=crypto.randomUUID();await p.insertInto("campaigns").values({...n,uuid:s}).execute();let r=await p.selectFrom("campaigns").selectAll().where("uuid","=",s).executeTakeFirst();if(!r)throw Error("[newsletter] Campaign could not be read back after creation");return r},async find(e){return await p.selectFrom("campaigns").selectAll().where("id","=",e).executeTakeFirst()},async update(e,t){let n=await m.find(e);if(!n)throw Error(`[newsletter] Campaign ${e} not found`);if(n.status!=="draft"&&n.status!=="scheduled")throw Error(`[newsletter] Cannot edit campaign in status '${n.status}'`);let r=t.emailListId!==void 0||t.emailListSlug!==void 0?await S(t):void 0;return n.update(B(t,r))},async sendNow(e,t={}){let n=await m.find(e);if(!n)throw Error(`[newsletter] Campaign ${e} not found`);if(!I(n.status))throw Error(`[newsletter] Campaign ${e} in status '${n.status}' cannot be queued`);let s=x(n),r={status:"sending",scheduledAt:s.scheduledAt,updatedAt:b(),attemptId:crypto.randomUUID()};return await C(e,s,r),await A(e,r,s,t),{ok:!0,campaignId:e}},async schedule(e,t,n={}){let s=await m.find(e);if(!s)throw Error(`[newsletter] Campaign ${e} not found`);if(!I(s.status))throw Error(`[newsletter] Campaign ${e} in status '${s.status}' cannot be scheduled`);let r=t instanceof Date?t:new Date(t);if(!Number.isFinite(r.getTime()))throw Error("[newsletter] scheduledAt must be a valid date");let i=Math.max(0,Math.floor((r.getTime()-Date.now())/1000)),a=r.toISOString(),u=x(s),c={status:i===0?"sending":"scheduled",scheduledAt:a,updatedAt:b(),attemptId:crypto.randomUUID()};return await C(e,u,c),await A(e,c,u,n,i>0?a:void 0,i),{ok:!0,campaignId:e,scheduledAt:a}},async cancel(e){let t=await m.find(e);if(!t)throw Error(`[newsletter] Campaign ${e} not found`);if(t.status==="sent")throw Error(`[newsletter] Campaign ${e} already sent. It cannot be cancelled`);let n=x(t),s={status:"cancelled",scheduledAt:n.scheduledAt,updatedAt:b(),attemptId:crypto.randomUUID()};return await C(e,n,s),m.find(e)}};function J(e){let t=2166136261;for(let n=0;n<e.length;n++)t^=e.charCodeAt(n),t=Math.imul(t,16777619);return t>>>0}function ue(e,t){if(t.length===0)throw Error("At least one delivery variant is required");let n=t.reduce((i,a)=>i+a.allocation,0);if(n<=0)throw Error("Delivery variant allocation must be greater than zero");let s=J(e)%n,r=0;for(let i of t){if(!Number.isFinite(i.allocation)||i.allocation<0)throw Error("Delivery variant allocation must be a non-negative number");if(r+=i.allocation,s<r)return i}return t[t.length-1]}function oe(e){return["delivery",e.teamId,e.campaignId,e.channel,e.recipient.trim().toLowerCase(),e.occurrence||"once"].join(":")}function de(e){let t=e.find((n)=>{if(n.limit===null)return!1;return n.used+(n.requested??1)>n.limit});if(t)throw Error(`Usage limit exceeded for ${t.meter}`)}function le(e,t=""){let n=t.toLowerCase();if(["suppressed","unsubscribe","complaint","stop"].some((s)=>n.includes(s)))return"suppressed";if(Number(e)===429||n.includes("rate"))return"rate_limited";if(Number(e)>=500||["timeout","temporary","unavailable"].some((s)=>n.includes(s)))return"transient";return"permanent"}function me(e){if(!e.url||!/^https?:\/\//i.test(e.url))throw Error("[newsletter] List-Unsubscribe URL must be absolute (http/https)");let t=[];if(e.mailto)t.push(`<mailto:${e.mailto}>`);return t.push(`<${e.url}>`),{"List-Unsubscribe":t.join(", "),"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}}import{db as o}from"@stacksjs/database";async function K(e){if(e==null){let n=await d.find("default");if(!n)n=await d.create({name:"Default",slug:"default"});return n.id}let t=await d.find(e);if(!t)throw Error(`[newsletter] List '${String(e)}' not found`);return t.id}async function g(e,t={}){if(!e||!e.includes("@"))throw Error("[newsletter] subscribe() requires a valid email address");let n=await K(t.list),s=t.source??"api",r=await o.selectFrom("subscribers").selectAll().where("email","=",e).executeTakeFirst();if(!r)await o.insertInto("subscribers").values({uuid:crypto.randomUUID(),email:e,status:"subscribed",source:s}).execute(),r=await o.selectFrom("subscribers").selectAll().where("email","=",e).executeTakeFirst();let i=await o.selectFrom("email_list_subscribers").selectAll().where("subscriber_id","=",Number(r.id)).where("email_list_id","=",n).executeTakeFirst();if(i){if(i.status==="unsubscribed")await o.updateTable("email_list_subscribers").set({status:"subscribed",unsubscribed_at:null}).where("id","=",Number(i.id)).execute();return{created:!1,email:e,listId:n,token:i.uuid}}let a=crypto.randomUUID();return await o.insertInto("email_list_subscribers").values({uuid:a,subscriber_id:Number(r.id),email_list_id:n,status:"subscribed",source:s}).execute(),{created:!0,email:e,listId:n,token:a}}async function U(e){if(!e)return{ok:!1};let t=await o.selectFrom("email_list_subscribers").selectAll().where("uuid","=",e).executeTakeFirst();if(!t)return{ok:!1};let n=async()=>(await o.selectFrom("subscribers").select(["email"]).where("id","=",Number(t.subscriber_id)).executeTakeFirst())?.email;if(t.status==="unsubscribed")return{ok:!0,alreadyUnsubscribed:!0,email:await n(),listId:t.email_list_id};return await o.updateTable("email_list_subscribers").set({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}).where("id","=",Number(t.id)).execute(),{ok:!0,email:await n(),listId:t.email_list_id}}async function F(e){let t=await o.selectFrom("subscribers").select(["id"]).where("email","=",e).executeTakeFirst();if(!t)return 0;let n=await o.selectFrom("email_list_subscribers").select(["id"]).where("subscriber_id","=",Number(t.id)).where("status","=","subscribed").execute();for(let s of n)await o.updateTable("email_list_subscribers").set({status:"unsubscribed",unsubscribed_at:new Date().toISOString()}).where("id","=",Number(s.id)).execute();return n.length}var xe={lists:d,campaigns:m,subscribe:g,unsubscribe:U,unsubscribeAll:F};import{db as k}from"@stacksjs/database";function w(e){return Boolean(e)&&e.includes("@")&&!e.startsWith("@")&&!e.endsWith("@")}function G(e,t){if(!w(e))return"skip-invalid";if(t.duplicate)return"skip-duplicate";if(t.suppressed)return"skip-suppressed";if(t.listStatus==="unsubscribed")return"skip-unsubscribed";return t.listStatus==="subscribed"?"existing":"add"}async function ve(e,t,n={}){let s={added:0,existing:0,unsubscribed:0,suppressed:0,invalid:0,skipped:{unsubscribed:[],suppressed:[],invalid:[]}},r=new Set;for(let i of t){let a=String(i.email??""),u=a.trim().toLowerCase(),c=null;if(w(u)&&!r.has(u)){let v=await k.selectFrom("subscribers").select(["id"]).where("email","=",u).executeTakeFirst();if(v)c=(await k.selectFrom("email_list_subscribers").select(["status"]).where("subscriber_id","=",Number(v.id)).where("email_list_id","=",await Y(e)).executeTakeFirst())?.status??null}let h=G(u,{duplicate:r.has(u),suppressed:w(u)?await X(u):!1,listStatus:c});if(w(u))r.add(u);switch(h){case"skip-invalid":s.invalid++,s.skipped.invalid.push(a);continue;case"skip-duplicate":continue;case"skip-suppressed":s.suppressed++,s.skipped.suppressed.push(u);continue;case"skip-unsubscribed":s.unsubscribed++,s.skipped.unsubscribed.push(u);continue;case"existing":s.existing++;continue;case"add":break}if((await g(u,{list:e,source:i.source??n.source??"sync",upsert:!0})).created)s.added++;else s.existing++}return s}async function X(e){try{let{isSuppressed:t}=await import("@stacksjs/email");return await t(e)}catch{return!1}}async function Y(e){if(typeof e==="number")return e;let t=await k.selectFrom("email_lists").select(["id"]).where("slug","=",e).executeTakeFirst();return Number(t?.id??0)}class l extends Error{code;constructor(e,t){let n={unavailable:`Usage meter unavailable for ${t}`,invalid:`Usage meter state is invalid for ${t}`,exceeded:`Usage limit exceeded for ${t}`,contention:`Usage reservation contention for ${t}`};super(n[e]);this.code=e;this.name="UsageQuotaError"}}function T(e){return Number.isFinite(e)&&e>0}function R(e,t){return e.meter===t&&Number.isFinite(e.used)&&e.used>=0&&(e.limit===null||Number.isFinite(e.limit)&&e.limit>=0)}function E(e){return Number.isInteger(e)&&e>0?e:1}async function De(e,t,n=1,s=5){if(!t||!T(n))throw new l("invalid",t||"unknown");for(let r=0;r<E(s);r++){let i=await e.read(t);if(!i)throw new l("unavailable",t);if(!R(i,t))throw new l("invalid",t);let a=i.used+n;if(i.limit!==null&&a>i.limit)throw new l("exceeded",t);if(await e.compareAndSet(i,a))return{meter:t,quantity:n,previousUsed:i.used,used:a,limit:i.limit}}throw new l("contention",t)}async function Ie(e,t,n=5){if(!t.meter||!T(t.quantity))throw new l("invalid",t.meter||"unknown");for(let s=0;s<E(n);s++){let r=await e.read(t.meter);if(!r)throw new l("unavailable",t.meter);if(!R(r,t.meter)||r.used<t.quantity)throw new l("invalid",t.meter);if(await e.compareAndSet(r,r.used-t.quantity))return}throw new l("contention",t.meter)}export{_ as CampaignStateConflictError,l as UsageQuotaError,ue as allocateDeliveryVariant,de as assertUsageAvailable,me as buildUnsubscribeHeaders,z as campaignCreateData,Q as campaignDeliveryDispatchKey,x as campaignDeliverySnapshot,B as campaignUpdateData,m as campaigns,I as canQueueCampaignStatus,le as classifyDeliveryFailure,oe as deliveryIdempotencyKey,q as emailListCreateData,d as lists,w as looksLikeEmail,xe as newsletter,Ie as releaseUsage,De as reserveUsage,ie as shouldRunScheduledCampaign,g as subscribe,G as syncDecision,ve as syncListSubscribers,U as unsubscribe,F as unsubscribeAll};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/newsletter",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.46",
5
+ "version": "0.74.47",
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,16 +61,16 @@
61
61
  "prepublishOnly": "bun run build"
62
62
  },
63
63
  "dependencies": {
64
- "@stacksjs/database": "0.74.46",
65
- "@stacksjs/email": "0.74.46",
66
- "@stacksjs/orm": "0.74.46",
67
- "@stacksjs/queue": "0.74.46"
64
+ "@stacksjs/database": "0.74.47",
65
+ "@stacksjs/email": "0.74.47",
66
+ "@stacksjs/orm": "0.74.47",
67
+ "@stacksjs/queue": "0.74.47"
68
68
  },
69
69
  "devDependencies": {
70
- "@stacksjs/cli": "0.74.46",
71
- "@stacksjs/config": "0.74.46",
72
- "@stacksjs/error-handling": "0.74.46",
73
- "@stacksjs/types": "0.74.46",
70
+ "@stacksjs/cli": "0.74.47",
71
+ "@stacksjs/config": "0.74.47",
72
+ "@stacksjs/error-handling": "0.74.47",
73
+ "@stacksjs/types": "0.74.47",
74
74
  "better-dx": "^0.2.24"
75
75
  }
76
76
  }