@stacksjs/newsletter 0.75.452 → 0.75.457
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/index.js +1 -1
- package/dist/subscriptions.d.ts +31 -0
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import{db as p,getDatabaseDialect as O,mutationCount as V,parseSqlDateTime as j,sqlDateTime as b,sqlHelpers as M}from"@stacksjs/database/runtime";import{db as f,matchedRows as P}from"@stacksjs/database/runtime";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 k(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 S 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){let t=String(y(e,"status","status")||"");if(!Q(t))throw TypeError(`[newsletter] Invalid campaign status '${t}'`);return{status:t,scheduledAt:D(y(e,"scheduled_at","scheduledAt")),updatedAt:D(y(e,"updated_at","updatedAt"))}}function Q(e){return["draft","scheduled","sending","sent","paused","cancelled","failed","active","completed","archived"].some((n)=>n===e)}function W(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 H(e,t){return t===null?e.whereNull("updated_at"):e.where("updated_at","=",t)}async function C(e,t,n){let s=(r)=>H(L(r.where("id","=",e).where("status","=",t.status),t.scheduledAt),t.updatedAt),i=(r)=>s(r.updateTable("campaigns").set({status:n.status,scheduled_at:n.scheduledAt,updated_at:n.updatedAt}));if(!M(O()).isMysql){if(V(await i(p).executeTakeFirst())!==1)throw new S(e);return}await p.transaction(async(r)=>{let a=r;if(!await s(a.selectFrom("campaigns")).select(["id"]).lockForUpdate().executeTakeFirst())throw new S(e);await i(a).execute()})}async function z(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,i,r=0){let{job:a}=await import("@stacksjs/queue"),u=i?"scheduled":"immediate";try{await a("SendCampaignJob",{campaignId:e,chunkSize:s.chunkSize??50,dryRun:s.dryRun??!1,...i?{scheduledAt:i}:{}}).onQueue("campaigns").delay(r).withIdempotencyKey(W(e,u,t.attemptId,i)).dispatch()}catch(c){try{await z(e,t,n)}catch(h){throw AggregateError([c,h],`[newsletter] Campaign ${e} could not be dispatched or restored`)}throw c}}function ae(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(),i=new Date(t).getTime();return Number.isFinite(s)&&Number.isFinite(i)&&s===i}function B(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 J(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 k(e),n=B(e,t),s=crypto.randomUUID();await p.insertInto("campaigns").values({...n,uuid:s}).execute();let i=await p.selectFrom("campaigns").selectAll().where("uuid","=",s).executeTakeFirst();if(!i)throw Error("[newsletter] Campaign could not be read back after creation");return i},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 i=t.emailListId!==void 0||t.emailListSlug!==void 0?await k(t):void 0;return n.update(J(t,i))},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),i={status:"sending",scheduledAt:s.scheduledAt,updatedAt:b(),attemptId:crypto.randomUUID()};return await C(e,s,i),await A(e,i,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 i=t instanceof Date?t:new Date(t);if(!Number.isFinite(i.getTime()))throw Error("[newsletter] scheduledAt must be a valid date");let r=Math.max(0,Math.floor((i.getTime()-Date.now())/1000)),a=i.toISOString(),u=x(s),c={status:r===0?"sending":"scheduled",scheduledAt:a,updatedAt:b(),attemptId:crypto.randomUUID()};return await C(e,u,c),await A(e,c,u,n,r>0?a:void 0,r),{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 K(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 oe(e,t){if(t.length===0)throw Error("At least one delivery variant is required");let n=t.reduce((r,a)=>r+a.allocation,0);if(n<=0)throw Error("Delivery variant allocation must be greater than zero");let s=K(e)%n,i=0;for(let r of t){if(!Number.isFinite(r.allocation)||r.allocation<0)throw Error("Delivery variant allocation must be a non-negative number");if(i+=r.allocation,s<i)return r}return t[t.length-1]}function de(e){return["delivery",e.teamId,e.campaignId,e.channel,e.recipient.trim().toLowerCase(),e.occurrence||"once"].join(":")}function le(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 ce(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 pe(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/runtime";async function G(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 G(t.list),s=t.source??"api",i=await o.selectFrom("subscribers").selectAll().where("email","=",e).executeTakeFirst();if(!i)await o.insertInto("subscribers").values({uuid:crypto.randomUUID(),email:e,status:"subscribed",source:s}).execute(),i=await o.selectFrom("subscribers").selectAll().where("email","=",e).executeTakeFirst();let r=await o.selectFrom("email_list_subscribers").selectAll().where("subscriber_id","=",Number(i.id)).where("email_list_id","=",n).executeTakeFirst();if(r){if(r.status==="unsubscribed")await o.updateTable("email_list_subscribers").set({status:"subscribed",unsubscribed_at:null}).where("id","=",Number(r.id)).execute();return{created:!1,email:e,listId:n,token:r.uuid}}let a=crypto.randomUUID();return await o.insertInto("email_list_subscribers").values({uuid:a,subscriber_id:Number(i.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 Ce={lists:d,campaigns:m,subscribe:g,unsubscribe:U,unsubscribeAll:F};import{db as v}from"@stacksjs/database/runtime";function w(e){return Boolean(e)&&e.includes("@")&&!e.startsWith("@")&&!e.endsWith("@")}function X(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 ke(e,t,n={}){let s={added:0,existing:0,unsubscribed:0,suppressed:0,invalid:0,skipped:{unsubscribed:[],suppressed:[],invalid:[]}},i=new Set;for(let r of t){let a=String(r.email??""),u=a.trim().toLowerCase(),c=null;if(w(u)&&!i.has(u)){let _=await v.selectFrom("subscribers").select(["id"]).where("email","=",u).executeTakeFirst();if(_)c=(await v.selectFrom("email_list_subscribers").select(["status"]).where("subscriber_id","=",Number(_.id)).where("email_list_id","=",await Z(e)).executeTakeFirst())?.status??null}let h=X(u,{duplicate:i.has(u),suppressed:w(u)?await Y(u):!1,listStatus:c});if(w(u))i.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:r.source??n.source??"sync",upsert:!0})).created)s.added++;else s.existing++}return s}async function Y(e){try{let{isSuppressed:t}=await import("@stacksjs/email");return await t(e)}catch{return!1}}async function Z(e){if(typeof e==="number")return e;let t=await v.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 Ie(e,t,n=1,s=5){if(!t||!T(n))throw new l("invalid",t||"unknown");for(let i=0;i<E(s);i++){let r=await e.read(t);if(!r)throw new l("unavailable",t);if(!R(r,t))throw new l("invalid",t);let a=r.used+n;if(r.limit!==null&&a>r.limit)throw new l("exceeded",t);if(await e.compareAndSet(r,a))return{meter:t,quantity:n,previousUsed:r.used,used:a,limit:r.limit}}throw new l("contention",t)}async function Ae(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 i=await e.read(t.meter);if(!i)throw new l("unavailable",t.meter);if(!R(i,t.meter)||i.used<t.quantity)throw new l("invalid",t.meter);if(await e.compareAndSet(i,i.used-t.quantity))return}throw new l("contention",t.meter)}export{S as CampaignStateConflictError,l as UsageQuotaError,oe as allocateDeliveryVariant,le as assertUsageAvailable,pe as buildUnsubscribeHeaders,B as campaignCreateData,W as campaignDeliveryDispatchKey,x as campaignDeliverySnapshot,J as campaignUpdateData,m as campaigns,I as canQueueCampaignStatus,ce as classifyDeliveryFailure,de as deliveryIdempotencyKey,q as emailListCreateData,d as lists,w as looksLikeEmail,Ce as newsletter,Ae as releaseUsage,Ie as reserveUsage,ae as shouldRunScheduledCampaign,g as subscribe,X as syncDecision,ke as syncListSubscribers,U as unsubscribe,F as unsubscribeAll};
|
|
2
|
+
import{db as p,getDatabaseDialect as M,mutationCount as H,parseSqlDateTime as W,sqlDateTime as b,sqlHelpers as z}from"@stacksjs/database/runtime";import{db as f,matchedRows as O}from"@stacksjs/database/runtime";function V(e){return e.toLowerCase().trim().replace(/[^\w\s-]+/g,"").replace(/\s+/g,"-").replace(/-+/g,"-")}function j(e){return{name:e.name,slug:e.slug??V(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 o={async create(e){let t=j(e);await f.insertInto("email_lists").values(t).execute();let n=await o.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 o.find(e);if(!t)throw Error(`[newsletter] List '${String(e)}' not found`);return(await O(f.updateTable("email_lists").set({status:"archived"}).where("id","=",Number(t.id)))).length}};async function k(e){if(e.emailListId)return e.emailListId;if(e.emailListSlug){let t=await o.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 C(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=W(e);return t===null?null:b(t)}function S(e){let t=String(C(e,"status","status")||"");if(!Q(t))throw TypeError(`[newsletter] Invalid campaign status '${t}'`);return{status:t,scheduledAt:D(C(e,"scheduled_at","scheduledAt")),updatedAt:D(C(e,"updated_at","updatedAt"))}}function Q(e){return["draft","scheduled","sending","sent","paused","cancelled","failed","active","completed","archived"].some((n)=>n===e)}function B(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 J(e,t){return t===null?e.whereNull("updated_at"):e.where("updated_at","=",t)}async function v(e,t,n){let s=(i)=>J(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(!z(M()).isMysql){if(H(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 K(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(B(e,u,t.attemptId,r)).dispatch()}catch(l){try{await K(e,t,n)}catch(x){throw AggregateError([l,x],`[newsletter] Campaign ${e} could not be dispatched or restored`)}throw l}}function le(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 Y(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 G(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 k(e),n=Y(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 k(t):void 0;return n.update(G(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=S(n),r={status:"sending",scheduledAt:s.scheduledAt,updatedAt:b(),attemptId:crypto.randomUUID()};return await v(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=S(s),l={status:i===0?"sending":"scheduled",scheduledAt:a,updatedAt:b(),attemptId:crypto.randomUUID()};return await v(e,u,l),await A(e,l,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=S(t),s={status:"cancelled",scheduledAt:n.scheduledAt,updatedAt:b(),attemptId:crypto.randomUUID()};return await v(e,n,s),m.find(e)}};function X(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 pe(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=X(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 be(e){return["delivery",e.teamId,e.campaignId,e.channel,e.recipient.trim().toLowerCase(),e.occurrence||"once"].join(":")}function fe(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 ge(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 he(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 d,getDatabaseDialect as Z,sqlDateTime as U,sqlHelpers as ee}from"@stacksjs/database/runtime";function w(e){return e.trim().toLowerCase()}async function g(e){let t=ee(Z()).param(1);return(await d.unsafe(`SELECT id FROM subscribers WHERE lower(email) = ${t} ORDER BY id`,[w(e)])).map((s)=>Number(s.id))}async function E(e,t){let n=[];for(let s of e){let r=await d.selectFrom("email_list_subscribers").selectAll().where("subscriber_id","=",s).where("email_list_id","=",t).executeTakeFirst();if(r)n.push(r)}return n}async function R(e,t){let n=await E(await g(e),t);if(n.some((s)=>s.status==="unsubscribed"))return"unsubscribed";return n.find((s)=>s.status==="subscribed")?.status??n[0]?.status??null}async function te(e){if(e==null){let n=await o.find("default");if(!n)n=await o.create({name:"Default",slug:"default"});return n.id}let t=await o.find(e);if(!t)throw Error(`[newsletter] List '${String(e)}' not found`);return t.id}async function h(e,t={}){if(!e||!e.includes("@"))throw Error("[newsletter] subscribe() requires a valid email address");e=w(e);let n=await te(t.list),s=t.source??"api",r=await g(e);if(!r.length)await d.insertInto("subscribers").values({uuid:crypto.randomUUID(),email:e,status:"subscribed",source:s}).execute(),r=await g(e);let i=r[0],a=(await E(r,n))[0];if(a){if(a.status==="unsubscribed")await d.updateTable("email_list_subscribers").set({status:"subscribed",unsubscribed_at:null}).where("id","=",Number(a.id)).execute();return{created:!1,email:e,listId:n,token:a.uuid}}let u=crypto.randomUUID();return await d.insertInto("email_list_subscribers").values({uuid:u,subscriber_id:i,email_list_id:n,status:"subscribed",source:s}).execute(),{created:!0,email:e,listId:n,token:u}}async function F(e){if(!e)return{ok:!1};let t=await d.selectFrom("email_list_subscribers").selectAll().where("uuid","=",e).executeTakeFirst();if(!t)return{ok:!1};let n=async()=>(await d.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 d.updateTable("email_list_subscribers").set({status:"unsubscribed",unsubscribed_at:U()}).where("id","=",Number(t.id)).execute(),{ok:!0,email:await n(),listId:t.email_list_id}}async function T(e){let t=[];for(let n of await g(e))t.push(...await d.selectFrom("email_list_subscribers").select(["id"]).where("subscriber_id","=",n).where("status","=","subscribed").execute());for(let n of t)await d.updateTable("email_list_subscribers").set({status:"unsubscribed",unsubscribed_at:U()}).where("id","=",Number(n.id)).execute();return t.length}var De={lists:o,campaigns:m,subscribe:h,unsubscribe:F,unsubscribeAll:T};import{db as ne}from"@stacksjs/database/runtime";function y(e){return Boolean(e)&&e.includes("@")&&!e.startsWith("@")&&!e.endsWith("@")}function se(e,t){if(!y(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 Ue(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=w(a),l=y(u)&&!r.has(u)?await R(u,await ie(e)):null,x=se(u,{duplicate:r.has(u),suppressed:y(u)?await re(u):!1,listStatus:l});if(y(u))r.add(u);switch(x){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 h(u,{list:e,source:i.source??n.source??"sync",upsert:!0})).created)s.added++;else s.existing++}return s}async function re(e){try{let{isSuppressed:t}=await import("@stacksjs/email");return await t(e)}catch{return!1}}async function ie(e){if(typeof e==="number")return e;let t=await ne.selectFrom("email_lists").select(["id"]).where("slug","=",e).executeTakeFirst();return Number(t?.id??0)}class c 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 P(e){return Number.isFinite(e)&&e>0}function N(e,t){return e.meter===t&&Number.isFinite(e.used)&&e.used>=0&&(e.limit===null||Number.isFinite(e.limit)&&e.limit>=0)}function q(e){return Number.isInteger(e)&&e>0?e:1}async function Re(e,t,n=1,s=5){if(!t||!P(n))throw new c("invalid",t||"unknown");for(let r=0;r<q(s);r++){let i=await e.read(t);if(!i)throw new c("unavailable",t);if(!N(i,t))throw new c("invalid",t);let a=i.used+n;if(i.limit!==null&&a>i.limit)throw new c("exceeded",t);if(await e.compareAndSet(i,a))return{meter:t,quantity:n,previousUsed:i.used,used:a,limit:i.limit}}throw new c("contention",t)}async function Fe(e,t,n=5){if(!t.meter||!P(t.quantity))throw new c("invalid",t.meter||"unknown");for(let s=0;s<q(n);s++){let r=await e.read(t.meter);if(!r)throw new c("unavailable",t.meter);if(!N(r,t.meter)||r.used<t.quantity)throw new c("invalid",t.meter);if(await e.compareAndSet(r,r.used-t.quantity))return}throw new c("contention",t.meter)}export{_ as CampaignStateConflictError,c as UsageQuotaError,pe as allocateDeliveryVariant,fe as assertUsageAvailable,he as buildUnsubscribeHeaders,Y as campaignCreateData,B as campaignDeliveryDispatchKey,S as campaignDeliverySnapshot,G as campaignUpdateData,m as campaigns,I as canQueueCampaignStatus,ge as classifyDeliveryFailure,be as deliveryIdempotencyKey,j as emailListCreateData,R as listStatusForEmail,o as lists,y as looksLikeEmail,De as newsletter,w as normalizeEmail,Fe as releaseUsage,Re as reserveUsage,le as shouldRunScheduledCampaign,h as subscribe,g as subscriberIdsForEmail,se as syncDecision,Ue as syncListSubscribers,F as unsubscribe,T as unsubscribeAll};
|
package/dist/subscriptions.d.ts
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
import type { SubscribeOptions, SubscribeResult, UnsubscribeResult } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Subscribe / unsubscribe primitives.
|
|
4
|
+
*
|
|
5
|
+
* Subscriptions are stored on the `email_list_subscribers` pivot — that's
|
|
6
|
+
* what makes "same email on multiple lists" work without breaking the
|
|
7
|
+
* unique constraint on `subscribers.email`. Each pivot row carries its
|
|
8
|
+
* own UUID, which doubles as the per-list unsubscribe token so a user
|
|
9
|
+
* unsubscribing from "Weekly Digest" doesn't accidentally drop "Order
|
|
10
|
+
* Confirmations".
|
|
11
|
+
*/
|
|
12
|
+
/** An address as one person: trimmed and lowercased, the way it is stored. */
|
|
13
|
+
export declare function normalizeEmail(email: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Every subscriber row for an address, oldest first, whatever case each was
|
|
16
|
+
* stored in.
|
|
17
|
+
*
|
|
18
|
+
* Addresses used to be stored as typed while the sync looked them up
|
|
19
|
+
* lowercased, so `Alice@Example.com` and `alice@example.com` were two people:
|
|
20
|
+
* a sync re-subscribed someone who had opted out under the other spelling,
|
|
21
|
+
* and a bounce for one spelling left the other on the list
|
|
22
|
+
* (stacksjs/stacks#3155). New rows are stored normalized; rows already stored
|
|
23
|
+
* with capitals are matched here, so they still count as the same person.
|
|
24
|
+
* The address is bound, never inlined.
|
|
25
|
+
*/
|
|
26
|
+
export declare function subscriberIdsForEmail(email: string): Promise<number[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Where an address stands on a list: unsubscribed when any of its rows there
|
|
29
|
+
* is, subscribed when one is, otherwise null. An opt-out under one spelling
|
|
30
|
+
* stands for the person.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listStatusForEmail(email: string, listId: number): Promise<string | null>;
|
|
2
33
|
export declare function subscribe(email: string, options?: SubscribeOptions): Promise<SubscribeResult>;
|
|
3
34
|
export declare function unsubscribe(token: string): Promise<UnsubscribeResult>;
|
|
4
35
|
/** Bulk unsubscribe by email — used by bounce/complaint handlers. */
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/newsletter",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.457",
|
|
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.75.
|
|
65
|
-
"@stacksjs/email": "0.75.
|
|
66
|
-
"@stacksjs/orm": "0.75.
|
|
67
|
-
"@stacksjs/queue": "0.75.
|
|
64
|
+
"@stacksjs/database": "0.75.457",
|
|
65
|
+
"@stacksjs/email": "0.75.457",
|
|
66
|
+
"@stacksjs/orm": "0.75.457",
|
|
67
|
+
"@stacksjs/queue": "0.75.457"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@stacksjs/cli": "0.75.
|
|
71
|
-
"@stacksjs/config": "0.75.
|
|
72
|
-
"@stacksjs/error-handling": "0.75.
|
|
73
|
-
"@stacksjs/types": "0.75.
|
|
70
|
+
"@stacksjs/cli": "0.75.457",
|
|
71
|
+
"@stacksjs/config": "0.75.457",
|
|
72
|
+
"@stacksjs/error-handling": "0.75.457",
|
|
73
|
+
"@stacksjs/types": "0.75.457",
|
|
74
74
|
"better-dx": "^0.2.24"
|
|
75
75
|
}
|
|
76
76
|
}
|