@stacksjs/email 0.72.37 → 0.72.39

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/email.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";import{CaptureEmailDriver}from"./drivers/capture";import{LogEmailDriver}from"./drivers/log";import{MailgunDriver}from"./drivers/mailgun";import{MailtrapDriver}from"./drivers/mailtrap";import{SendGridDriver}from"./drivers/sendgrid";import{SESDriver}from"./drivers/ses";import{SMTPDriver}from"./drivers/smtp";import{findEmailByIdempotencyKey,recordEmailIdempotency}from"./idempotency";import{checkSuppressionFor}from"./suppression";export class EmailDeliveryError extends Error{result;constructor(result){super(result.message||`Email delivery failed through ${result.provider||"the configured provider"}.`);this.name="EmailDeliveryError";this.result=result}}export class Email{name;subject;to;from;template;handle;onError;onSuccess;constructor(options){this.name=options.name;this.subject=options.subject;this.to=options.to;this.from=options.from;this.template=options.template;this.handle=options.handle;this.onError=options.onError;this.onSuccess=options.onSuccess}async renderTemplate(){if(!this.template)return"";try{const{path:p}=await import("@stacksjs/path"),templatePath=p.resourcesPath(`views/emails/${this.template}.html`),file=Bun.file(templatePath);if(await file.exists())return await file.text()}catch{}if(this.template.includes("<"))return this.template;return`<p>${this.template}</p>`}async send(to){const target=to??this.to,recipients=Array.isArray(target)?target:target?[target]:[];if(recipients.length===0)throw Error("No recipient specified for email");try{await mail.sendOrFail({to:recipients,from:this.from||{name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"},subject:this.subject,html:await this.renderTemplate()});if(this.onSuccess)this.onSuccess();if(this.handle)return this.handle();return{message:"Email sent"}}catch(error){if(this.onError)return this.onError(error instanceof Error?error:Error(String(error)));throw error}}}export class Mail{drivers=new Map;defaultDriver;constructor(options={}){this.defaultDriver=options.defaultDriver||config.email.default||"ses";this.registerDefaultDrivers()}registerDefaultDrivers(){this.drivers.set("log",new LogEmailDriver);this.drivers.set("ses",new SESDriver);this.drivers.set("sendgrid",new SendGridDriver);this.drivers.set("mailgun",new MailgunDriver);this.drivers.set("mailtrap",new MailtrapDriver);this.drivers.set("smtp",new SMTPDriver);this.drivers.set("capture",new CaptureEmailDriver)}async send(message){const driver=this.drivers.get(this.defaultDriver);if(!driver){const available=[...this.drivers.keys()].sort().join(", ");throw Error(`Email driver '${this.defaultDriver}' is not registered. Available drivers: [${available}]. Check config.email.default or the MAIL_MAILER environment variable.`)}if(message.idempotencyKey){const cached=await findEmailByIdempotencyKey(message.idempotencyKey);if(cached)return cached}const suppressionType=await checkSuppressionForFirstRecipient(message);if(suppressionType)return{success:!1,message:`suppressed:${suppressionType}`,provider:"suppression"};const defaultFrom={name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"},result=await driver.send({...message,from:message.from||defaultFrom});if(message.idempotencyKey)await recordEmailIdempotency(message.idempotencyKey,message,result);return result}async sendOrFail(message){const result=await this.send(message);if(!result.success)throw new EmailDeliveryError(result);return result}use(driver){if(!this.drivers.has(driver))throw Error(`Email driver '${driver}' is not available`);return new Mail({defaultDriver:driver})}async queue(message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue("emails").dispatch()},{context:"queue"})}async later(delaySeconds,message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue("emails").delay(delaySeconds).dispatch()},{context:"later",delaySeconds})}async queueOn(queueName,message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue(queueName).dispatch()},{context:"queueOn",queueName})}async dispatchOrFallback(message,dispatch,logExtra){try{await dispatch();return}catch(error){const reason=error instanceof Error?error.message:String(error);log.warn("[email] Queue dispatch failed; falling back to synchronous send. "+"Background email pipeline is degraded \u2014 check the queue worker / broker.",{...logExtra,reason})}await this.send(message)}}async function checkSuppressionForFirstRecipient(message){const recipients=collectRecipientAddresses(message);if(recipients.length===0)return null;for(const addr of recipients){const matched=await checkSuppressionFor(addr,message.tag);if(matched)return matched}return null}function collectRecipientAddresses(message){const out=[],pushOne=(v)=>{if(typeof v==="string"){out.push(v);return}if(v&&typeof v==="object"&&"address"in v&&typeof v.address==="string")out.push(v.address)};for(const field of["to","cc","bcc"]){const v=message[field];if(!v)continue;if(Array.isArray(v))for(const item of v)pushOne(item);else pushOne(v)}return out}let _mail;function getMail(){if(!_mail){const driver=config?.email?.default||process.env.MAIL_MAILER||"ses";_mail=new Mail({defaultDriver:driver})}return _mail}export const mail=new Proxy({},{get(_t,prop){return getMail()[prop]},set(_t,prop,value){getMail()[prop]=value;return!0}});
1
+ import{config}from"@stacksjs/config";import{log}from"@stacksjs/logging";import{CaptureEmailDriver}from"./drivers/capture";import{LogEmailDriver}from"./drivers/log";import{MailgunDriver}from"./drivers/mailgun";import{MailtrapDriver}from"./drivers/mailtrap";import{SendGridDriver}from"./drivers/sendgrid";import{SESDriver}from"./drivers/ses";import{SMTPDriver}from"./drivers/smtp";import{findEmailByIdempotencyKey,recordEmailIdempotency}from"./idempotency";import{checkSuppressionFor}from"./suppression";export class EmailDeliveryError extends Error{result;constructor(result){super(result.message||`Email delivery failed through ${result.provider||"the configured provider"}.`);this.name="EmailDeliveryError";this.result=result}}export class Email{name;subject;to;from;template;handle;onError;onSuccess;constructor(options){this.name=options.name;this.subject=options.subject;this.to=options.to;this.from=options.from;this.template=options.template;this.handle=options.handle;this.onError=options.onError;this.onSuccess=options.onSuccess}async renderTemplate(){if(!this.template)return"";try{const{path:p}=await import("@stacksjs/path"),templatePath=p.resourcesPath(`views/emails/${this.template}.html`),file=Bun.file(templatePath);if(await file.exists())return await file.text()}catch{}if(this.template.includes("<"))return this.template;return`<p>${this.template}</p>`}async send(to){const target=to??this.to,recipients=Array.isArray(target)?target:target?[target]:[];if(recipients.length===0)throw Error("No recipient specified for email");try{await mail.sendOrFail({to:recipients,from:this.from||{name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"},subject:this.subject,html:await this.renderTemplate()});if(this.onSuccess)this.onSuccess();if(this.handle)return this.handle();return{message:"Email sent"}}catch(error){if(this.onError)return this.onError(error instanceof Error?error:Error(String(error)));throw error}}}export class Mail{drivers=new Map;defaultDriver;constructor(options={}){this.defaultDriver=options.defaultDriver||config.email.default||"ses";this.registerDefaultDrivers()}registerDefaultDrivers(){this.drivers.set("log",new LogEmailDriver);this.drivers.set("ses",new SESDriver);this.drivers.set("sendgrid",new SendGridDriver);this.drivers.set("mailgun",new MailgunDriver);this.drivers.set("mailtrap",new MailtrapDriver);this.drivers.set("smtp",new SMTPDriver);this.drivers.set("capture",new CaptureEmailDriver)}async send(message){const driver=this.drivers.get(this.defaultDriver);if(!driver){const available=[...this.drivers.keys()].sort().join(", ");throw Error(`Email driver '${this.defaultDriver}' is not registered. Available drivers: [${available}]. Check config.email.default or the MAIL_MAILER environment variable.`)}if(message.idempotencyKey){const cached=await findEmailByIdempotencyKey(message.idempotencyKey);if(cached)return cached}const suppressionType=await checkSuppressionForFirstRecipient(message);if(suppressionType)return{success:!1,message:`suppressed:${suppressionType}`,provider:"suppression"};const defaultFrom={name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"},result=await driver.send({...message,from:message.from||defaultFrom});if(message.idempotencyKey)await recordEmailIdempotency(message.idempotencyKey,message,result);return result}async sendOrFail(message){const result=await this.send(message);if(!result.success)throw new EmailDeliveryError(result);return result}use(driver){if(!this.drivers.has(driver))throw Error(`Email driver '${driver}' is not available`);return new Mail({defaultDriver:driver})}async queue(message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue("emails").dispatch()},{context:"queue"})}async later(delaySeconds,message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue("emails").delay(delaySeconds).dispatch()},{context:"later",delaySeconds})}async queueOn(queueName,message){await this.dispatchOrFallback(message,async()=>{const{job}=await import("@stacksjs/queue");await job("SendEmail",{message,driver:this.defaultDriver}).onQueue(queueName).dispatch()},{context:"queueOn",queueName})}async dispatchOrFallback(message,dispatch,logExtra){try{await dispatch();return}catch(error){const reason=error instanceof Error?error.message:String(error);log.warn("[email] Queue dispatch failed; falling back to synchronous send. Background email pipeline is degraded - check the queue worker / broker.",{...logExtra,reason})}await this.send(message)}}async function checkSuppressionForFirstRecipient(message){const recipients=collectRecipientAddresses(message);if(recipients.length===0)return null;for(const addr of recipients){const matched=await checkSuppressionFor(addr,message.tag);if(matched)return matched}return null}function collectRecipientAddresses(message){const out=[],pushOne=(v)=>{if(typeof v==="string"){out.push(v);return}if(v&&typeof v==="object"&&"address"in v&&typeof v.address==="string")out.push(v.address)};for(const field of["to","cc","bcc"]){const v=message[field];if(!v)continue;if(Array.isArray(v))for(const item of v)pushOne(item);else pushOne(v)}return out}let _mail;function getMail(){if(!_mail){const driver=config?.email?.default||process.env.MAIL_MAILER||"ses";_mail=new Mail({defaultDriver:driver})}return _mail}export const mail=new Proxy({},{get(_t,prop){return getMail()[prop]},set(_t,prop,value){getMail()[prop]=value;return!0}});
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database";let warnedAboutMissingEmailIdempotencyTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingEmailIdempotencyTable)return;warnedAboutMissingEmailIdempotencyTable=!0;console.warn("[email/idempotency] email_idempotency table missing \u2014 idempotency keys are accepted but NOT enforced. "+"Run migrations to enable dedup.")}function isMissingTableError(err){const msg=(err?.message??"").toLowerCase();return msg.includes("no such table")||msg.includes("doesn't exist")||msg.includes("connection closed")||msg.includes("unable to open database")||msg.includes("econnrefused")||msg.includes("connection terminated")||msg.includes("no database")||msg.includes("database connection")}export async function findEmailByIdempotencyKey(key){try{const row=await db.selectFrom("email_idempotency").where("idempotency_key","=",key).selectAll().executeTakeFirst();if(!row)return null;return{success:Boolean(row.success),message:`Idempotent replay \u2014 original send recorded ${row.created_at}`,provider:String(row.provider??"cache"),messageId:row.message_id??void 0}}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return null}throw err}}export async function recordEmailIdempotency(key,message,result){if(!result.success)return;const recipient=Array.isArray(message.to)?message.to.map((t)=>typeof t==="string"?t:t.address).join(", "):typeof message.to==="string"?message.to:message.to.address;try{await db.insertInto("email_idempotency").values({idempotency_key:key,message_id:result.messageId??null,recipient,subject:message.subject,provider:result.provider,success:1,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return;throw err}}
1
+ import{db}from"@stacksjs/database";let warnedAboutMissingEmailIdempotencyTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingEmailIdempotencyTable)return;warnedAboutMissingEmailIdempotencyTable=!0;console.warn("[email/idempotency] email_idempotency table missing - idempotency keys are accepted but NOT enforced. Run migrations to enable dedup.")}function isMissingTableError(err){const msg=(err?.message??"").toLowerCase();return msg.includes("no such table")||msg.includes("doesn't exist")||msg.includes("connection closed")||msg.includes("unable to open database")||msg.includes("econnrefused")||msg.includes("connection terminated")||msg.includes("no database")||msg.includes("database connection")}export async function findEmailByIdempotencyKey(key){try{const row=await db.selectFrom("email_idempotency").where("idempotency_key","=",key).selectAll().executeTakeFirst();if(!row)return null;return{success:Boolean(row.success),message:`Idempotent replay - original send recorded ${row.created_at}`,provider:String(row.provider??"cache"),messageId:row.message_id??void 0}}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return null}throw err}}export async function recordEmailIdempotency(key,message,result){if(!result.success)return;const recipient=Array.isArray(message.to)?message.to.map((t)=>typeof t==="string"?t:t.address).join(", "):typeof message.to==="string"?message.to:message.to.address;try{await db.insertInto("email_idempotency").values({idempotency_key:key,message_id:result.messageId??null,recipient,subject:message.subject,provider:result.provider,success:1,created_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute()}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return;throw err}}
package/dist/mailable.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{mail}from"./email";import{template as renderTemplate}from"./template";export class Mailable{_to=[];_cc=[];_bcc=[];_replyTo;_from;_subject;_text;_html;_template;_attachments=[];to(address){this._to=normalizeAddresses(address);return this}cc(address){this._cc=normalizeAddresses(address);return this}bcc(address){this._bcc=normalizeAddresses(address);return this}replyTo(address){this._replyTo=typeof address==="string"?{address}:address;return this}from(addr){this._from=addr;return this}subject(s){this._subject=s;return this}text(body){this._text=body;return this}html(body){this._html=body;return this}template(name,...rest){const props=rest[0]??{};this._template={name,props};return this}inspect(){return{to:this._to,cc:this._cc,bcc:this._bcc,from:this._from,replyTo:this._replyTo,subject:this._subject,text:this._text,html:this._html,template:this._template,attachments:this._attachments}}attach(path,name){this._attachments.push({filename:name||basename(path),content:`__file__:${path}`,encoding:"binary"});return this}attachData(buffer,name,mime){this._attachments.push({filename:name,content:buffer,contentType:mime,encoding:typeof buffer==="string"?"utf8":"binary"});return this}async send(options={}){await Promise.resolve(this.build());if(this._to.length===0)throw Error("[Mailable] no recipients \u2014 call this.to(...) inside build()");if(!this._subject)throw Error("[Mailable] no subject \u2014 call this.subject(...) inside build()");let html=this._html,text=this._text;if(this._template){const rendered=await renderTemplate(this._template.name,{variables:this._template.props,subject:this._subject});if(!html)html=rendered.html;if(!text)text=rendered.text}const message={to:this._to,subject:this._subject,from:this._from||resolveDefaultFrom(),...this._cc.length?{cc:this._cc}:{},...this._bcc.length?{bcc:this._bcc}:{},...html?{html}:{},...text?{text}:{},...this._attachments.length?{attachments:await materializeAttachments(this._attachments)}:{}};if(this._replyTo)message.replyTo=this._replyTo;return(options.driver?mail.use(options.driver):mail).send(message)}}function normalizeAddresses(input){const arr=Array.isArray(input)?input:[input];if(arr.some((a)=>typeof a==="object"&&a!==null))return arr.map((a)=>typeof a==="string"?{address:a}:a);return arr}function resolveDefaultFrom(){return{name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"}}function basename(p){const idx=Math.max(p.lastIndexOf("/"),p.lastIndexOf("\\"));return idx===-1?p:p.slice(idx+1)}async function materializeAttachments(atts){return Promise.all(atts.map(async(att)=>{if(typeof att.content==="string"&&att.content.startsWith("__file__:")){const filePath=att.content.slice(9),file=Bun.file(filePath),buf=new Uint8Array(await file.arrayBuffer());return{...att,content:buf,contentType:att.contentType||file.type||void 0,encoding:"binary"}}return att}))}
1
+ import{config}from"@stacksjs/config";import{mail}from"./email";import{template as renderTemplate}from"./template";export class Mailable{_to=[];_cc=[];_bcc=[];_replyTo;_from;_subject;_text;_html;_template;_attachments=[];to(address){this._to=normalizeAddresses(address);return this}cc(address){this._cc=normalizeAddresses(address);return this}bcc(address){this._bcc=normalizeAddresses(address);return this}replyTo(address){this._replyTo=typeof address==="string"?{address}:address;return this}from(addr){this._from=addr;return this}subject(s){this._subject=s;return this}text(body){this._text=body;return this}html(body){this._html=body;return this}template(name,...rest){const props=rest[0]??{};this._template={name,props};return this}inspect(){return{to:this._to,cc:this._cc,bcc:this._bcc,from:this._from,replyTo:this._replyTo,subject:this._subject,text:this._text,html:this._html,template:this._template,attachments:this._attachments}}attach(path,name){this._attachments.push({filename:name||basename(path),content:`__file__:${path}`,encoding:"binary"});return this}attachData(buffer,name,mime){this._attachments.push({filename:name,content:buffer,contentType:mime,encoding:typeof buffer==="string"?"utf8":"binary"});return this}async send(options={}){await Promise.resolve(this.build());if(this._to.length===0)throw Error("[Mailable] no recipients - call this.to(...) inside build()");if(!this._subject)throw Error("[Mailable] no subject - call this.subject(...) inside build()");let html=this._html,text=this._text;if(this._template){const rendered=await renderTemplate(this._template.name,{variables:this._template.props,subject:this._subject});if(!html)html=rendered.html;if(!text)text=rendered.text}const message={to:this._to,subject:this._subject,from:this._from||resolveDefaultFrom(),...this._cc.length?{cc:this._cc}:{},...this._bcc.length?{bcc:this._bcc}:{},...html?{html}:{},...text?{text}:{},...this._attachments.length?{attachments:await materializeAttachments(this._attachments)}:{}};if(this._replyTo)message.replyTo=this._replyTo;return(options.driver?mail.use(options.driver):mail).send(message)}}function normalizeAddresses(input){const arr=Array.isArray(input)?input:[input];if(arr.some((a)=>typeof a==="object"&&a!==null))return arr.map((a)=>typeof a==="string"?{address:a}:a);return arr}function resolveDefaultFrom(){return{name:config.email.from?.name||"Stacks",address:config.email.from?.address||"no-reply@stacksjs.com"}}function basename(p){const idx=Math.max(p.lastIndexOf("/"),p.lastIndexOf("\\"));return idx===-1?p:p.slice(idx+1)}async function materializeAttachments(atts){return Promise.all(atts.map(async(att)=>{if(typeof att.content==="string"&&att.content.startsWith("__file__:")){const filePath=att.content.slice(9),file=Bun.file(filePath),buf=new Uint8Array(await file.arrayBuffer());return{...att,content:buf,contentType:att.contentType||file.type||void 0,encoding:"binary"}}return att}))}
@@ -1,4 +1,4 @@
1
- const ROOT="/_stacks/mail/preview";function escape(s){return String(s??"").replace(/[&<>"']/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function fmtAddresses(addresses){if(!addresses?.length)return"\u2014";return addresses.map((a)=>{if(typeof a==="string")return escape(a);if(a.name)return`${escape(a.name)} &lt;${escape(a.address)}&gt;`;return escape(a.address)}).join(", ")}function shell(title,body){return`<!DOCTYPE html>
1
+ const ROOT="/_stacks/mail/preview";function escape(s){return String(s??"").replace(/[&<>"']/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function fmtAddresses(addresses){if(!addresses?.length)return"-";return addresses.map((a)=>{if(typeof a==="string")return escape(a);if(a.name)return`${escape(a.name)} &lt;${escape(a.address)}&gt;`;return escape(a.address)}).join(", ")}function shell(title,body){return`<!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
@@ -67,7 +67,7 @@ const ROOT="/_stacks/mail/preview";function escape(s){return String(s??"").repla
67
67
  <code>resources/emails/_previews/&lt;slug&gt;.ts</code> for richer previews.</p>
68
68
  <ul class="list">${items}</ul>
69
69
  </div>
70
- `)}export function renderPreviewHtml(mailable,preview,view="desktop"){const{inspection,text,sampleProps,error}=preview,baseUrl=`${ROOT}/${mailable.slug}`,rawUrl=`${baseUrl}/raw`,errorBlock=error?`<div class="panel"><div class="error"><strong>${escape(mailable.name)} failed to render:</strong> ${escape(error)}</div></div>`:"",iframeBlock=error?"":view==="text"?`<div class="panel"><pre>${escape(text||"(no plain-text body)")}</pre></div>`:`<div class="panel"><iframe src="${rawUrl}" class="${view==="mobile"?"mobile":""}" title="${escape(mailable.name)} body" sandbox="allow-same-origin"></iframe></div>`,sampleHint=sampleProps?`<dd><code>resources/emails/_previews/${escape(mailable.slug)}.ts</code></dd>`:`<dd class="muted">No sample file \u2014 edit <code>resources/emails/_previews/${escape(mailable.slug)}.ts</code> to customize.</dd>`;return shell(mailable.name,`
70
+ `)}export function renderPreviewHtml(mailable,preview,view="desktop"){const{inspection,text,sampleProps,error}=preview,baseUrl=`${ROOT}/${mailable.slug}`,rawUrl=`${baseUrl}/raw`,errorBlock=error?`<div class="panel"><div class="error"><strong>${escape(mailable.name)} failed to render:</strong> ${escape(error)}</div></div>`:"",iframeBlock=error?"":view==="text"?`<div class="panel"><pre>${escape(text||"(no plain-text body)")}</pre></div>`:`<div class="panel"><iframe src="${rawUrl}" class="${view==="mobile"?"mobile":""}" title="${escape(mailable.name)} body" sandbox="allow-same-origin"></iframe></div>`,sampleHint=sampleProps?`<dd><code>resources/emails/_previews/${escape(mailable.slug)}.ts</code></dd>`:`<dd class="muted">No sample file - edit <code>resources/emails/_previews/${escape(mailable.slug)}.ts</code> to customize.</dd>`;return shell(mailable.name,`
71
71
  ${errorBlock}
72
72
 
73
73
  <div class="grid">
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[email/suppression] email_suppressions table missing \u2014 suppression checks accepted but NOT enforced. "+"Run migrations to enable enforcement.")}export function isMissingTableError(err){const e=err,msg=e?.message??"";return e?.code==="42P01"||msg.includes("no such table")||msg.includes("doesn't exist")||/relation "[^"]*" does not exist/i.test(msg)}function isSuppressionStoreUnavailable(err){if(isMissingTableError(err))return!0;const msg=(err?.message??"").toLowerCase();return msg.includes("connection closed")||msg.includes("unable to open database")||msg.includes("econnrefused")||msg.includes("connection terminated")||msg.includes("no database")||msg.includes("database connection")}function canonicalize(email){return String(email).trim().toLowerCase()}export async function isSuppressed(email,type){const canon=canonicalize(email);try{let query=db.selectFrom("email_suppressions").where("email","=",canon).select(["email"]);if(type)query=query.where("type","=",type);const row=await query.executeTakeFirst();return Boolean(row)}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function getSuppressions(email){const canon=canonicalize(email);try{return await db.selectFrom("email_suppressions").where("email","=",canon).selectAll().execute()??[]}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function suppress(email,type,reason){const canon=canonicalize(email),createdAt=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("email_suppressions").values({email:canon,type,reason:reason??null,created_at:createdAt}).execute()}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return;throw err}}export async function unsuppress(email,type){const canon=canonicalize(email);try{await db.deleteFrom("email_suppressions").where("email","=",canon).where("type","=",type).execute()}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return}throw err}}export async function getSuppressionPolicy(){try{const{config}=await import("@stacksjs/config"),policy=config?.email?.suppressionPolicy;if(policy==="strict"||policy==="transactional-allowed"||policy==="off")return policy}catch{}return"strict"}export async function checkSuppressionFor(email,tag){const policy=await getSuppressionPolicy();if(policy==="off")return null;if(policy==="transactional-allowed"&&tag==="transactional")return null;const suppressions=await getSuppressions(email);if(suppressions.length===0)return null;const priority=["unsubscribe","complaint","bounce","manual"];for(const p of priority)if(suppressions.some((s)=>s.type===p))return p;return suppressions[0].type}
1
+ import{db}from"@stacksjs/database";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[email/suppression] email_suppressions table missing - suppression checks accepted but NOT enforced. Run migrations to enable enforcement.")}export function isMissingTableError(err){const e=err,msg=e?.message??"";return e?.code==="42P01"||msg.includes("no such table")||msg.includes("doesn't exist")||/relation "[^"]*" does not exist/i.test(msg)}function isSuppressionStoreUnavailable(err){if(isMissingTableError(err))return!0;const msg=(err?.message??"").toLowerCase();return msg.includes("connection closed")||msg.includes("unable to open database")||msg.includes("econnrefused")||msg.includes("connection terminated")||msg.includes("no database")||msg.includes("database connection")}function canonicalize(email){return String(email).trim().toLowerCase()}export async function isSuppressed(email,type){const canon=canonicalize(email);try{let query=db.selectFrom("email_suppressions").where("email","=",canon).select(["email"]);if(type)query=query.where("type","=",type);const row=await query.executeTakeFirst();return Boolean(row)}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return!1}throw err}}export async function getSuppressions(email){const canon=canonicalize(email);try{return await db.selectFrom("email_suppressions").where("email","=",canon).selectAll().execute()??[]}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return[]}throw err}}export async function suppress(email,type,reason){const canon=canonicalize(email),createdAt=new Date().toISOString().slice(0,19).replace("T"," ");try{await db.insertInto("email_suppressions").values({email:canon,type,reason:reason??null,created_at:createdAt}).execute()}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return;throw err}}export async function unsuppress(email,type){const canon=canonicalize(email);try{await db.deleteFrom("email_suppressions").where("email","=",canon).where("type","=",type).execute()}catch(err){if(isSuppressionStoreUnavailable(err)){warnOnceAboutMissingTable();return}throw err}}export async function getSuppressionPolicy(){try{const{config}=await import("@stacksjs/config"),policy=config?.email?.suppressionPolicy;if(policy==="strict"||policy==="transactional-allowed"||policy==="off")return policy}catch{}return"strict"}export async function checkSuppressionFor(email,tag){const policy=await getSuppressionPolicy();if(policy==="off")return null;if(policy==="transactional-allowed"&&tag==="transactional")return null;const suppressions=await getSuppressions(email);if(suppressions.length===0)return null;const priority=["unsubscribe","complaint","bounce","manual"];for(const p of priority)if(suppressions.some((s)=>s.type===p))return p;return suppressions[0].type}
@@ -1 +1 @@
1
- import{db}from"@stacksjs/database";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[email/webhook-dedup] email_webhook_events table missing \u2014 webhook idempotency NOT enforced. "+"Providers may double-deliver retries; run migrations to enable dedup.")}function isMissingTableError(err){const msg=err?.message??"";return msg.includes("no such table")||msg.includes("doesn't exist")}export async function recordWebhookEventOrSkip(provider,eventId){if(!eventId)return!0;try{await db.insertInto("email_webhook_events").values({provider,event_id:eventId,processed_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!0}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return!1;throw err}}
1
+ import{db}from"@stacksjs/database";let warnedAboutMissingTable=!1;function warnOnceAboutMissingTable(){if(warnedAboutMissingTable)return;warnedAboutMissingTable=!0;console.warn("[email/webhook-dedup] email_webhook_events table missing - webhook idempotency NOT enforced. Providers may double-deliver retries; run migrations to enable dedup.")}function isMissingTableError(err){const msg=err?.message??"";return msg.includes("no such table")||msg.includes("doesn't exist")}export async function recordWebhookEventOrSkip(provider,eventId){if(!eventId)return!0;try{await db.insertInto("email_webhook_events").values({provider,event_id:eventId,processed_at:new Date().toISOString().slice(0,19).replace("T"," ")}).execute();return!0}catch(err){if(isMissingTableError(err)){warnOnceAboutMissingTable();return!0}const msg=err?.message??"";if(msg.includes("UNIQUE constraint")||msg.includes("Duplicate entry"))return!1;throw err}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/email",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.37",
5
+ "version": "0.72.39",
6
6
  "description": "The Stacks Email integration. Painlessly create & manage your inboxes, templates, and send emails.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -64,10 +64,10 @@
64
64
  "postal-mime": "^2.7.6"
65
65
  },
66
66
  "devDependencies": {
67
- "@stacksjs/cli": "0.72.37",
68
- "@stacksjs/config": "0.72.37",
67
+ "@stacksjs/cli": "0.72.39",
68
+ "@stacksjs/config": "0.72.39",
69
69
  "better-dx": "^0.2.24",
70
- "@stacksjs/error-handling": "0.72.37",
71
- "@stacksjs/types": "0.72.37"
70
+ "@stacksjs/error-handling": "0.72.39",
71
+ "@stacksjs/types": "0.72.39"
72
72
  }
73
73
  }