@stacksjs/notifications 0.75.144 → 0.75.146

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/README.md CHANGED
@@ -63,6 +63,21 @@ and `payload.smsSender` starts every text with `{sender}: `.
63
63
  The signed token turns off one channel + category preference; handle the
64
64
  app's route with `unsubscribeWithToken(await unsubscribeTokenFromRequest(request))`.
65
65
 
66
+ ## The frame's language
67
+
68
+ Every email's footer, copyright line, unsubscribe notice and link labels, the
69
+ default button label and a marketing text's opt-out line are the frame's, not
70
+ the message's. `payload.locale` renders them in the reader's language (English
71
+ and German built in, `de-AT` → `de` → `en`) and sets `<html lang>`:
72
+
73
+ ```ts
74
+ await notify({ userId, email }, { ...templates.render('order_receipt', variables, language), locale: language }, ['email'])
75
+ ```
76
+
77
+ Another language: pass every `NotificationFrameStrings` key as
78
+ `payload.strings` (`{app}` and `{year}` are filled in); a partial set overrides
79
+ only what it names. Without a locale the frame is the English it always was.
80
+
66
81
  ## Templates
67
82
 
68
83
  `@stacksjs/notifications/templates` (also re-exported from the package root)
@@ -1,3 +1,4 @@
1
+ import { type NotificationFrameStrings } from './frame-strings';
1
2
  import type { EmailMessage } from '@stacksjs/types';
2
3
  /**
3
4
  * A plain-text notification body as the paragraphs of its email. Blank lines
@@ -14,10 +15,13 @@ export declare function emailParagraphs(body: string): EmailBodyPart[][];
14
15
  *
15
16
  * With `unsubscribe`, the footer says why the email came and links to
16
17
  * Unsubscribe (and the preferences page) instead of "do not reply".
18
+ *
19
+ * The footer, link and default button words are the frame's, in the
20
+ * payload's `locale` (see {@link NotificationFrameLocale}); English without one.
17
21
  */
18
22
  export declare function notificationEmailHtml(payload: NotificationEmailPayload): Promise<string>;
19
- /** The plain-text part, with the action's link and any unsubscribe link spelled out. */
20
- export declare function notificationEmailText(payload: { body: string, action?: NotificationAction, unsubscribe?: NotificationUnsubscribe }): string;
23
+ /** The plain-text part, with the action's link and any unsubscribe link spelled out, in the payload's `locale`. */
24
+ export declare function notificationEmailText(payload: { body: string, action?: NotificationAction, unsubscribe?: NotificationUnsubscribe } & NotificationFrameLocale): string;
21
25
  /**
22
26
  * `List-Unsubscribe` for a payload's unsubscribe links (RFC 2369), plus
23
27
  * `List-Unsubscribe-Post` when there is a one-click endpoint (RFC 8058).
@@ -39,20 +43,22 @@ export declare function smsSenderPrefix(sender?: string): string;
39
43
  /**
40
44
  * What a notification texts. With `smsSender` it starts with the sender's
41
45
  * name, so a text from a shared number says who it is from (carriers expect
42
- * the brand in every message). A `marketing` text ends with
43
- * {@link DEFAULT_SMS_OPT_OUT} on its own line; `smsOptOut` replaces that line
44
- * (and adds it to any category), and `false` leaves it out. A body that
45
- * already starts with the name, or already contains the line, is left alone.
46
+ * the brand in every message). A `marketing` text ends with the frame's
47
+ * `smsOptOut` line in the payload's `locale` ({@link DEFAULT_SMS_OPT_OUT} in
48
+ * English) on its own line; `smsOptOut` replaces that line (and adds it to any
49
+ * category), and `false` leaves it out. A body that already starts with the
50
+ * name, or already contains the line, is left alone.
46
51
  */
47
- export declare function notificationSmsBody(payload: { body: string, smsSender?: string, smsOptOut?: string | false }, category?: string): string;
48
- /** The footer sentence of an email with an unsubscribe link, unless the payload names its own. */
49
- export declare const DEFAULT_UNSUBSCRIBE_NOTICE: 'You are receiving this because these emails are turned on in your settings.';
52
+ export declare function notificationSmsBody(payload: { body: string, smsSender?: string, smsOptOut?: string | false } & NotificationFrameLocale, category?: string): string;
53
+ /** The footer sentence of an email with an unsubscribe link, unless the payload names its own (English). */
54
+ export declare const DEFAULT_UNSUBSCRIBE_NOTICE: string;
50
55
  /**
51
56
  * The line a marketing text ends with, unless the payload names its own
52
- * (`smsOptOut`). Carriers expect opt-out instructions on marketing texts, and
53
- * STOP is the keyword every provider and `@stacksjs/sms` honour.
57
+ * (`smsOptOut`) or another language (`locale`). Carriers expect opt-out
58
+ * instructions on marketing texts, and STOP is the keyword every provider and
59
+ * `@stacksjs/sms` honour.
54
60
  */
55
- export declare const DEFAULT_SMS_OPT_OUT: 'Reply STOP to opt out.';
61
+ export declare const DEFAULT_SMS_OPT_OUT: string;
56
62
  /** The longest sender name a text starts with; a longer one is cut. */
57
63
  export declare const SMS_SENDER_MAX_LENGTH: 30;
58
64
  /** Where a notification leads: the email's button. */
@@ -70,8 +76,17 @@ export declare interface NotificationUnsubscribe {
70
76
  preferencesUrl?: string
71
77
  notice?: string
72
78
  }
79
+ /**
80
+ * The language of the frame a notification is sent in: its fixed footer,
81
+ * link and button words (see `NotificationFrameStrings`). Without either, the
82
+ * frame is English, exactly as before it could be localized.
83
+ */
84
+ export declare interface NotificationFrameLocale {
85
+ locale?: string | null
86
+ strings?: Partial<NotificationFrameStrings>
87
+ }
73
88
  /** What the email builders read from a notification payload. */
74
- export declare interface NotificationEmailPayload {
89
+ export declare interface NotificationEmailPayload extends NotificationFrameLocale {
75
90
  subject?: string
76
91
  body: string
77
92
  action?: NotificationAction
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The frame for one reader: the built-in language closest to `locale` (`de-AT`
3
+ * is `de`, anything without one is `en`), with `overrides` on top. An app
4
+ * that speaks a language Stacks does not ship passes the whole set as
5
+ * `overrides`; a key it leaves out (or leaves empty) stays English.
6
+ */
7
+ export declare function notificationFrameStrings(locale?: string | null, overrides?: Partial<NotificationFrameStrings> | null): NotificationFrameStrings;
8
+ /** The email's `<html lang>`: the reader's language tag as given, `en` when there is none (or it is not one). */
9
+ export declare function notificationLanguageTag(locale?: string | null): string;
10
+ /**
11
+ * The frame in every language Stacks ships. English is what every email and
12
+ * text said before the frame could be localized, word for word.
13
+ */
14
+ export declare const NOTIFICATION_FRAME_STRINGS: Readonly<Record<'en' | 'de', Readonly<NotificationFrameStrings>>>;
15
+ /**
16
+ * The fixed words around a notification: the email's footer lines, its
17
+ * unsubscribe links, the button label used when the action names none, and
18
+ * the line a marketing text ends with. The message itself (subject, body,
19
+ * button) is the caller's; these are the frame's.
20
+ *
21
+ * `{app}` is replaced by the app's name and `{year}` by the current year.
22
+ * Single braces, because the frame template fills them in and `{{ }}` is stx's
23
+ * own syntax there.
24
+ */
25
+ export declare interface NotificationFrameStrings {
26
+ automatedNotice: string
27
+ copyright: string
28
+ unsubscribeNotice: string
29
+ unsubscribe: string
30
+ preferences: string
31
+ action: string
32
+ smsOptOut: string
33
+ }
package/dist/index.d.ts CHANGED
@@ -3,13 +3,14 @@ import { chat, email, push, sms } from './drivers/index';
3
3
  import { DatabaseNotificationDriver } from './drivers/database';
4
4
  import { filterChannelsByPreferences } from './preferences';
5
5
  import { makeDeliveryRecord, recordNotificationDelivery } from './delivery';
6
- import { type NotificationAction, type NotificationUnsubscribe, notificationEmailMessage, notificationSmsBody } from './email-body';
6
+ import { type NotificationAction, type NotificationFrameLocale, type NotificationUnsubscribe, notificationEmailMessage, notificationSmsBody } from './email-body';
7
7
  import type { ChatMessage, ChatResult, EmailMessage, EmailResult, SmsMessage, SmsSendResult } from '@stacksjs/types';
8
8
  export type { BroadcastNotificationOptions, BroadcastNotificationResult } from './drivers/broadcast';
9
9
  export type { NotificationDeliveryRecord } from './delivery';
10
10
  export type { CreateNotificationOptions, DatabaseNotification } from './drivers/database';
11
11
  export type { NotificationPreferenceRow, PreferenceChannel } from './preferences';
12
- export type { EmailBodyPart, NotificationAction, NotificationEmailPayload, NotificationUnsubscribe } from './email-body';
12
+ export type { EmailBodyPart, NotificationAction, NotificationEmailPayload, NotificationFrameLocale, NotificationUnsubscribe } from './email-body';
13
+ export type { NotificationFrameStrings } from './frame-strings';
13
14
  export type { ParsedUnsubscribeToken, UnsubscribeChannel, UnsubscribeLinkOptions } from './unsubscribe';
14
15
  export type { AccountNotificationPaths, AccountNotificationTemplateName, CommerceNotificationPaths, CommerceNotificationTemplateName, NotificationTemplateAction, NotificationTemplateCatalog, NotificationTemplateCatalogOptions, NotificationTemplateCopy, NotificationTemplateDefinition, NotificationTemplateVariables, RenderedNotificationTemplate, RenderNotificationTemplateOptions, SubscriptionNotificationPaths, SubscriptionNotificationTemplateName } from './templates';
15
16
  export declare function ensureSuccessfulEmailResult(result: EmailResult): void;
@@ -92,7 +93,13 @@ export declare interface NotifyOptions {
92
93
  export declare interface EmailTransport {
93
94
  send: (message: EmailMessage) => Promise<EmailResult>
94
95
  }
95
- export declare interface NotificationPayload {
96
+ /**
97
+ * What `notify()` sends. `locale` (and `strings`, from
98
+ * {@link NotificationFrameLocale}) put the frame in the reader's language: the
99
+ * email's footer, unsubscribe links and default button label, and a marketing
100
+ * text's opt-out line. The subject and body are sent as given.
101
+ */
102
+ export declare interface NotificationPayload extends NotificationFrameLocale {
96
103
  subject?: string
97
104
  body: string
98
105
  data?: Record<string, unknown>
@@ -134,5 +141,6 @@ export {
134
141
  wherePreferenceCategory,
135
142
  } from './preferences';
136
143
  export { DEFAULT_SMS_OPT_OUT, DEFAULT_UNSUBSCRIBE_NOTICE, emailParagraphs, listUnsubscribeHeaders, notificationEmailHtml, notificationEmailMessage, notificationEmailText, notificationSmsBody, SMS_SENDER_MAX_LENGTH, smsSenderPrefix } from './email-body';
144
+ export { NOTIFICATION_FRAME_STRINGS, notificationFrameStrings, notificationLanguageTag } from './frame-strings';
137
145
  export { notificationUnsubscribeLinks, notificationUnsubscribeToken, parseNotificationUnsubscribeToken, unsubscribeTokenFromRequest, unsubscribeWithToken } from './unsubscribe';
138
146
  export { accountNotificationTemplates, commerceNotificationTemplates, createNotificationTemplates, defineNotificationTemplates, interpolateNotificationText, notificationTemplateVariables, renderNotificationTemplate, resolveNotificationLocale, subscriptionNotificationTemplates } from './templates';
package/dist/index.js CHANGED
@@ -1,14 +1,5 @@
1
1
  // @bun
2
- import{log as U}from"@stacksjs/cli";import{notification as Oe}from"@stacksjs/config";import{mail as ue}from"@stacksjs/email";import{log as A}from"@stacksjs/cli";var p={async send(e){let t=e.event??"notification",n=e.channel;if(!n)n=e.userId!==void 0?`private-user.${e.userId}`:"notifications";try{let i=await import("@stacksjs/realtime").catch(()=>null),r=i?.emit,a=i?.getServer;if(!r||!a)return{delivered:!1,channel:n,event:t,reason:"@stacksjs/realtime is not available"};if(!a())return{delivered:!1,channel:n,event:t,reason:"realtime server not running"};let s=e.private??e.userId!==void 0;return r(n,t,e.data,{private:s}),A.info(`[notifications] broadcast to '${n}' (${t})`),{delivered:!0,channel:n,event:t}}catch(i){let r=i instanceof Error?i.message:String(i);return A.warn(`[notifications] broadcast failed: ${r}`),{delivered:!1,channel:n,event:t,reason:r}}}};import*as j from"@stacksjs/chat";import{log as I}from"@stacksjs/cli";import{createHash as me}from"crypto";import{db as u,enqueueAfterCommit as pe,lockRow as be,sqlDateTime as T}from"@stacksjs/database/runtime";import{emit as ge,getServer as he}from"@stacksjs/realtime";function B(e){return`private-user.${e}`}function x(e,t,n){let i=()=>{try{if(he())ge(B(e),t,n)}catch{I.warn("Notification inbox broadcast failed; the persisted inbox remains available")}};if(!pe(i))i()}async function O(e,t){let n=T(),r=await u.insertInto("notifications").values({user_id:e.userId,...t?{uuid:t}:{},type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}).returning("id").execute(),a=Array.isArray(r)?r[0]:r,s=Number(a?.id??a?.insertId??a?.lastInsertRowid??a?.lastInsertId??0);if(!Number.isSafeInteger(s)||s<=0)throw Error("Database notification did not return a generated id");if(I.info(`Database notification sent to user ${e.userId}: ${e.type}`),e.broadcast!==!1)x(e.userId,"notifications.created",{id:s});return{id:s,user_id:e.userId,type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}}var b={async send(e){if(e.idempotencyKey!==void 0){if(!e.idempotencyKey||e.idempotencyKey.length>255)throw Error("A notification idempotency key must contain 1 to 255 characters");let t=me("sha256").update(JSON.stringify([e.userId,e.type,e.idempotencyKey])).digest("hex"),n=`${t.slice(0,8)}-${t.slice(8,12)}-8${t.slice(13,16)}-8${t.slice(17,20)}-${t.slice(20,32)}`;return u.transaction(async(i)=>{if(!await be(i,"users",{id:e.userId}))throw Error("Notification recipient not found");let a=await u.selectFrom("notifications").selectAll().where("user_id","=",e.userId).where("uuid","=",n).executeTakeFirst();if(a)return{...a,id:Number(a.id),user_id:Number(a.user_id)};return O(e,n)})}return O(e)},async getUserNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).orderBy("created_at","desc").execute()).map((n)=>({...n,id:Number(n.id),user_id:Number(n.user_id)}))},async getUnreadNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).whereNull("read_at").orderBy("created_at","desc").execute()).map((n)=>({...n,id:Number(n.id),user_id:Number(n.user_id)}))},async markAsRead(e,t){let n=u.updateTable("notifications").set({read_at:T()}).where("id","=",e).whereNull("read_at");if(t!==void 0)n=n.where("user_id","=",t);if(await n.execute(),t)x(t,"notifications.read",{id:e})},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:T()}).where("user_id","=",e).whereNull("read_at").execute(),x(e,"notifications.read",{all:!0})},async unreadCount(e){let t=await u.selectFrom("notifications").select(u.fn.countAll().as("count")).where("user_id","=",e).whereNull("read_at").executeTakeFirst();return Number(t?.count??0)},async deleteNotification(e,t){let n=u.deleteFrom("notifications").where("id","=",e);if(t!==void 0)n=n.where("user_id","=",t);await n.execute()},async deleteAllNotifications(e){await u.deleteFrom("notifications").where("user_id","=",e).execute()}};import*as L from"@stacksjs/push";import*as g from"@stacksjs/sms";import{log as ye}from"@stacksjs/cli";import{config as M}from"@stacksjs/config";import{db as Ne,sqlDateTime as F}from"@stacksjs/database/runtime";function K(e,t){switch(t){case"email":return e.email||"";case"sms":return e.phone||"";case"push":return Array.isArray(e.pushTokens)?e.pushTokens.join(", "):e.pushTokens||"";case"broadcast":return e.broadcastChannel||(e.userId?`private-user.${e.userId}`:"notifications");case"database":return e.userId?`User #${e.userId}`:"";case"chat":return Array.isArray(e.chatRecipient)?e.chatRecipient.join(", "):e.chatRecipient||""}}function k(e,t,n,i,r=F()){return{userId:e.userId,channel:n.channel,recipient:K(e,n.channel),subject:t.subject,body:t.body,status:n.success?"sent":"failed",error:n.error?.message,metadata:{...t.data,...i.category?{category:i.category}:{}},sentAt:r}}function we(e){let t=M.notification?.tracking;if(t?.enabled===!1)return!1;let n=t?.channels;if(!n||n.length===0)return!0;return n.includes(e)}async function P(e){if(!we(e.channel))return;let t=F();try{await Ne.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:M.notification?.tracking?.body===!1?"":e.body,status:e.status,error:e.error??null,metadata:e.metadata?JSON.stringify(e.metadata):null,sent_at:e.sentAt,created_at:t,updated_at:t}).execute()}catch(n){let i=n instanceof Error?n.message:String(n);ye.warn(`[notifications] could not record ${e.channel} delivery: ${i}`)}}import{templateByName as Te}from"@stacksjs/email";var h="You are receiving this because these emails are turned on in your settings.",z="Reply STOP to opt out.",xe=/https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]]/g;function q(e){return String(e??"").replace(/\r\n?/g,`
3
- `).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let n=[];return t.split(`
4
- `).forEach((i,r)=>{if(r>0)n.push({br:!0});let a=0;for(let s of i.matchAll(xe)){if(s.index>a)n.push({text:i.slice(a,s.index)});n.push({url:s[0]}),a=s.index+s[0].length}if(a<i.length)n.push({text:i.slice(a)})}),n})}function d(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t])}async function Y(e){let t=e.unsubscribe?.url?e.unsubscribe:void 0;try{let{html:r}=await Te("notification",{subject:e.subject??"",variables:{subject:e.subject??"",paragraphs:q(e.body),preheader:String(e.body??"").replace(/\s+/g," ").trim().slice(0,140),...e.action?.url?{actionUrl:e.action.url,actionLabel:e.action.label||"Open"}:{},...t?{unsubscribeUrl:t.url,unsubscribeNotice:t.notice||h,...t.preferencesUrl?{preferencesUrl:t.preferencesUrl}:{}}:{}}});if(r)return r}catch{}let n=e.action?.url?`<p><a href="${d(e.action.url)}">${d(e.action.label||e.action.url)}</a></p>`:"",i=t?`<p>${d(t.notice||h)} <a href="${d(t.url)}">Unsubscribe</a></p>`:"";return`<p>${d(e.body)}</p>${n}${i}`}function V(e){let t=e.action?.url?`${e.body}
5
-
6
- ${e.action.label||"Open"}: ${e.action.url}`:e.body;if(e.unsubscribe?.url)t+=`
7
-
8
- ${e.unsubscribe.notice||h}
9
- Unsubscribe: ${e.unsubscribe.url}`;return t}function H(e){if(!e?.url)return{};return{"List-Unsubscribe":`<${e.oneClickUrl||e.url}>`,...e.oneClickUrl?{"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}:{}}}async function _(e,t){let n={...t.headers,...H(t.unsubscribe)};return{to:e,subject:t.subject??"",text:V(t),html:await Y(t),...Object.keys(n).length?{headers:n}:{}}}var W=30;function G(e){let t=String(e??"").replace(/\s+/g," ").trim().slice(0,W).trim();return t?`${t}: `:""}function v(e,t){let n=String(e.body??""),i=G(e.smsSender);if(i&&!n.trimStart().toLowerCase().startsWith(i.trim().toLowerCase()))n=`${i}${n.trimStart()}`;if(e.smsOptOut===!1)return n;let r=typeof e.smsOptOut==="string"?e.smsOptOut.trim():t==="marketing"?z:"";if(!r||n.toLowerCase().includes(r.toLowerCase()))return n;return`${n.trimEnd()}
10
-
11
- ${r}`}import{log as ke}from"@stacksjs/cli";import{db as f,lockRow as J,sqlDateTime as Pe}from"@stacksjs/database/runtime";var y="notification_preferences";function R(e,t){return t===null?e.whereNull("category"):e.where("category","=",t)}async function E(e,t){let n=new Map;try{let r=await R(f.selectFrom(y).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let a of r)n.set(a.channel,a.enabled===!0||a.enabled===1)}catch(i){ke.debug?.(`[notifications] preferences lookup failed (table missing?): ${i.message}`)}return n}async function S(e,t,n,i){await f.transaction(async(r)=>{await J(r,"users",{id:e}),await Q(e,t,n,i)})}async function Q(e,t,n,i){if(!Number.isSafeInteger(e)||e<=0||typeof n!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let r=Pe(),a=i??null,s=f.selectFrom(y).select(["id"]).where("user_id","=",e).where("channel","=",t),o=await R(s,a).executeTakeFirst();if(o?.id){await f.updateTable(y).set({enabled:n,updated_at:r}).where("id","=",o.id).execute();return}await f.insertInto(y).values({user_id:e,channel:t,enabled:n,category:a,created_at:r,updated_at:r}).execute()}async function _e(e,t){await f.transaction(async(n)=>{await J(n,"users",{id:e});for(let i of t)await Q(e,i.channel,i.enabled,i.category)})}async function C(e,t,n){let i=await E(e,n);if(n!==void 0){let r=await E(e,void 0);for(let[a,s]of r)if(!s||!i.has(a))i.set(a,s)}return t.filter((r)=>{let a=i.get(r);return a===void 0?!0:a===!0})}import N from"process";import{createHmac as ve,timingSafeEqual as Ee}from"crypto";import{db as Re}from"@stacksjs/database/runtime";var Z=["email","sms"];function Se(){let e=N.env.APP_KEY;if((!e||e.length<16)&&(N.env.APP_ENV==="production"||N.env.NODE_ENV==="production"))throw Error("[notifications/unsubscribe] APP_KEY is missing or too short (need at least 16 characters). Cannot sign unsubscribe links.");return e||"stacks-default-key-dev-only-do-not-use-prod"}function X(e){return ve("sha256",Se()).update(e).digest("hex")}function ee(e,t,n="marketing"){if(!Number.isSafeInteger(e)||e<=0)throw Error("An unsubscribe token needs a positive user id");if(!Z.includes(t))throw Error(`Unsupported unsubscribe channel: ${t}`);if(!n||n.includes(":"))throw Error("An unsubscribe category must be non-empty and contain no colon");let i=`${e}:${t}:${n}`;return Buffer.from(`${i}:${X(i)}`).toString("base64url")}function te(e){try{let t=Buffer.from(String(e??""),"base64url").toString("utf8");if(!e||Buffer.from(t).toString("base64url")!==e)return null;let n=t.split(":");if(n.length!==4)return null;let[i,r,a,s]=n;if(!i||!a||!s||!Z.includes(r))return null;let o=X(`${i}:${r}:${a}`);if(s.length!==o.length||!Ee(Buffer.from(s),Buffer.from(o)))return null;let c=Number(i);return Number.isSafeInteger(c)&&c>0&&String(c)===i?{userId:c,channel:r,category:a}:null}catch{return null}}function Ce(e,t={}){let n=String(t.baseUrl||N.env.APP_URL||"http://localhost").replace(/\/$/,""),i=ee(e,t.channel??"email",t.category??"marketing"),r=(a)=>a.startsWith("/")?a:`/${a}`;return{url:`${n}${r(t.pagePath??"/unsubscribe")}?token=${i}`,oneClickUrl:`${n}${r(t.endpointPath??"/api/notifications/unsubscribe")}?token=${i}`,...t.preferencesPath===!1?{}:{preferencesUrl:`${n}${r(t.preferencesPath??"/settings")}`},...t.notice?{notice:t.notice}:{}}}async function De(e){let t=new URL(e.url,"http://localhost").searchParams.get("token");if(t)return t;let n=await e.json?.().catch(()=>({}));return String(n?.token||"")}async function Ue(e){let t=te(e);if(!t)return null;if(await Re.selectFrom("users").select(["id"]).where("id","=",t.userId).executeTakeFirst())await S(t.userId,t.channel,!1,t.category);return t}function ne(e={}){let t=e.resetPassword??"/forgot-password";return{password_changed:{copy:{en:{subject:"Your {{app}} password was changed",body:`The password for your {{app}} account was changed.
2
+ import{log as j}from"@stacksjs/cli";import{notification as Be}from"@stacksjs/config";import{mail as le}from"@stacksjs/email";import{log as I}from"@stacksjs/cli";var g={async send(e){let t=e.event??"notification",n=e.channel;if(!n)n=e.userId!==void 0?`private-user.${e.userId}`:"notifications";try{let i=await import("@stacksjs/realtime").catch(()=>null),r=i?.emit,a=i?.getServer;if(!r||!a)return{delivered:!1,channel:n,event:t,reason:"@stacksjs/realtime is not available"};if(!a())return{delivered:!1,channel:n,event:t,reason:"realtime server not running"};let s=e.private??e.userId!==void 0;return r(n,t,e.data,{private:s}),I.info(`[notifications] broadcast to '${n}' (${t})`),{delivered:!0,channel:n,event:t}}catch(i){let r=i instanceof Error?i.message:String(i);return I.warn(`[notifications] broadcast failed: ${r}`),{delivered:!1,channel:n,event:t,reason:r}}}};import*as L from"@stacksjs/chat";import{log as B}from"@stacksjs/cli";import{createHash as pe}from"crypto";import{db as u,enqueueAfterCommit as be,lockRow as ge,sqlDateTime as k}from"@stacksjs/database/runtime";import{emit as he,getServer as ye}from"@stacksjs/realtime";function M(e){return`private-user.${e}`}function P(e,t,n){let i=()=>{try{if(ye())he(M(e),t,n)}catch{B.warn("Notification inbox broadcast failed; the persisted inbox remains available")}};if(!be(i))i()}async function F(e,t){let n=k(),r=await u.insertInto("notifications").values({user_id:e.userId,...t?{uuid:t}:{},type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}).returning("id").execute(),a=Array.isArray(r)?r[0]:r,s=Number(a?.id??a?.insertId??a?.lastInsertRowid??a?.lastInsertId??0);if(!Number.isSafeInteger(s)||s<=0)throw Error("Database notification did not return a generated id");if(B.info(`Database notification sent to user ${e.userId}: ${e.type}`),e.broadcast!==!1)P(e.userId,"notifications.created",{id:s});return{id:s,user_id:e.userId,type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}}var h={async send(e){if(e.idempotencyKey!==void 0){if(!e.idempotencyKey||e.idempotencyKey.length>255)throw Error("A notification idempotency key must contain 1 to 255 characters");let t=pe("sha256").update(JSON.stringify([e.userId,e.type,e.idempotencyKey])).digest("hex"),n=`${t.slice(0,8)}-${t.slice(8,12)}-8${t.slice(13,16)}-8${t.slice(17,20)}-${t.slice(20,32)}`;return u.transaction(async(i)=>{if(!await ge(i,"users",{id:e.userId}))throw Error("Notification recipient not found");let a=await u.selectFrom("notifications").selectAll().where("user_id","=",e.userId).where("uuid","=",n).executeTakeFirst();if(a)return{...a,id:Number(a.id),user_id:Number(a.user_id)};return F(e,n)})}return F(e)},async getUserNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).orderBy("created_at","desc").execute()).map((n)=>({...n,id:Number(n.id),user_id:Number(n.user_id)}))},async getUnreadNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).whereNull("read_at").orderBy("created_at","desc").execute()).map((n)=>({...n,id:Number(n.id),user_id:Number(n.user_id)}))},async markAsRead(e,t){let n=u.updateTable("notifications").set({read_at:k()}).where("id","=",e).whereNull("read_at");if(t!==void 0)n=n.where("user_id","=",t);if(await n.execute(),t)P(t,"notifications.read",{id:e})},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:k()}).where("user_id","=",e).whereNull("read_at").execute(),P(e,"notifications.read",{all:!0})},async unreadCount(e){let t=await u.selectFrom("notifications").select(u.fn.countAll().as("count")).where("user_id","=",e).whereNull("read_at").executeTakeFirst();return Number(t?.count??0)},async deleteNotification(e,t){let n=u.deleteFrom("notifications").where("id","=",e);if(t!==void 0)n=n.where("user_id","=",t);await n.execute()},async deleteAllNotifications(e){await u.deleteFrom("notifications").where("user_id","=",e).execute()}};import*as z from"@stacksjs/push";import*as y from"@stacksjs/sms";import{log as Ne}from"@stacksjs/cli";import{config as K}from"@stacksjs/config";import{db as we,sqlDateTime as q}from"@stacksjs/database/runtime";function Y(e,t){switch(t){case"email":return e.email||"";case"sms":return e.phone||"";case"push":return Array.isArray(e.pushTokens)?e.pushTokens.join(", "):e.pushTokens||"";case"broadcast":return e.broadcastChannel||(e.userId?`private-user.${e.userId}`:"notifications");case"database":return e.userId?`User #${e.userId}`:"";case"chat":return Array.isArray(e.chatRecipient)?e.chatRecipient.join(", "):e.chatRecipient||""}}function _(e,t,n,i,r=q()){return{userId:e.userId,channel:n.channel,recipient:Y(e,n.channel),subject:t.subject,body:t.body,status:n.success?"sent":"failed",error:n.error?.message,metadata:{...t.data,...i.category?{category:i.category}:{}},sentAt:r}}function Te(e){let t=K.notification?.tracking;if(t?.enabled===!1)return!1;let n=t?.channels;if(!n||n.length===0)return!0;return n.includes(e)}async function v(e){if(!Te(e.channel))return;let t=q();try{await we.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:K.notification?.tracking?.body===!1?"":e.body,status:e.status,error:e.error??null,metadata:e.metadata?JSON.stringify(e.metadata):null,sent_at:e.sentAt,created_at:t,updated_at:t}).execute()}catch(n){let i=n instanceof Error?n.message:String(n);Ne.warn(`[notifications] could not record ${e.channel} delivery: ${i}`)}}import{templateByName as ve}from"@stacksjs/email";function V(e={}){let t=e.resetPassword??"/forgot-password";return{password_changed:{copy:{en:{subject:"Your {{app}} password was changed",body:`The password for your {{app}} account was changed.
12
3
 
13
4
  If you did this, there\u2019s nothing else to do. If you didn\u2019t, reset your password right away and let us know at {{support_email}}.`},de:{subject:"Dein {{app}}-Passwort wurde ge\xE4ndert",body:`Das Passwort f\xFCr dein {{app}}-Konto wurde ge\xE4ndert.
14
5
 
@@ -24,7 +15,7 @@ Hast du das nicht veranlasst, schreib uns an {{support_email}}.`}}},account_crea
24
15
 
25
16
  Set a password to sign in. The link works for {{days}} days; after that, use \u201CForgot password\u201D on the sign-in page.`},de:{subject:"{{inviter}} hat dich zu {{app}} hinzugef\xFCgt",body:`{{inviter}} hat f\xFCr dich ein {{app}}-Konto mit dieser E-Mail-Adresse angelegt.
26
17
 
27
- Lege ein Passwort fest, um dich anzumelden. Der Link gilt {{days}} Tage; danach nutze \u201EPasswort vergessen\u201C auf der Anmeldeseite.`}},action:{path:"{{set_password_url}}",label:{en:"Set your password",de:"Passwort festlegen"}}}}}function ie(e={}){let t=e.orders??"/orders",n=e.sales??"/orders";return{order_receipt:{copy:{en:{subject:"Your receipt from {{seller}}",body:`Thanks for your purchase from {{seller}}.
18
+ Lege ein Passwort fest, um dich anzumelden. Der Link gilt {{days}} Tage; danach nutze \u201EPasswort vergessen\u201C auf der Anmeldeseite.`}},action:{path:"{{set_password_url}}",label:{en:"Set your password",de:"Passwort festlegen"}}}}}function G(e={}){let t=e.orders??"/orders",n=e.sales??"/orders";return{order_receipt:{copy:{en:{subject:"Your receipt from {{seller}}",body:`Thanks for your purchase from {{seller}}.
28
19
 
29
20
  {{lines}}
30
21
  Total: {{total}}
@@ -46,4 +37,13 @@ Bestellung {{order_number}}.`}},action:{path:n,label:{en:"View your sales",de:"V
46
37
 
47
38
  Your balance is now {{balance}}. You can use it on your next purchase.`},de:{subject:"{{amount}} Guthaben von {{seller}}",body:`{{seller}} hat deinem Konto {{amount}} gutgeschrieben: {{reason}}
48
39
 
49
- Dein Guthaben betr\xE4gt jetzt {{balance}}. Du kannst es bei deinem n\xE4chsten Einkauf verwenden.`}}}}}function re(e={}){let t=e.billing??"/billing";return{subscription_trial_ending:{copy:{en:{subject:"Your {{app}} trial ends on {{date}}",body:"Your {{plan}} trial ends on {{date}}. To keep using {{plan}}, check that your payment details are up to date. To stop, cancel before then and you won\u2019t be charged."},de:{subject:"Deine {{app}}-Testphase endet am {{date}}",body:"Deine Testphase f\xFCr {{plan}} endet am {{date}}. Wenn du {{plan}} weiter nutzen willst, pr\xFCfe, ob deine Zahlungsdaten aktuell sind. Wenn nicht, k\xFCndige vorher, dann wird nichts berechnet."}},action:{path:t,label:{en:"Manage billing",de:"Abrechnung verwalten"}}},subscription_payment_failed:{copy:{en:{subject:"Payment for {{plan}} failed",body:"We couldn\u2019t charge your payment method for your {{app}} {{plan}} plan. Update it to keep {{plan}} active."},de:{subject:"Zahlung f\xFCr {{plan}} fehlgeschlagen",body:"Wir konnten deine Zahlungsmethode f\xFCr deinen {{app}}-Tarif {{plan}} nicht belasten. Aktualisiere sie, damit {{plan}} aktiv bleibt."}},action:{path:t,label:{en:"Update payment method",de:"Zahlungsmethode aktualisieren"}}},subscription_ended:{copy:{en:{subject:"Your {{app}} {{plan}} plan has ended",body:"Your {{plan}} plan ended on {{date}}. Your account and data are still here; choose a plan again whenever you\u2019re ready."},de:{subject:"Dein {{app}}-Tarif {{plan}} ist beendet",body:"Dein Tarif {{plan}} endete am {{date}}. Dein Konto und deine Daten bleiben erhalten; du kannst jederzeit wieder einen Tarif w\xE4hlen."}},action:{path:t,label:{en:"Choose a plan",de:"Tarif w\xE4hlen"}}}}}var ae=/\{\{\s*([a-z0-9_]+)\s*\}\}/gi;function Ae(e){return e}function w(e,t={}){return e.replace(ae,(n,i)=>{let r=t[i];return r==null?"":String(r)})}function oe(e){return[...new Set([...e.matchAll(ae)].map((t)=>t[1]))]}function se(e,t,n="en"){let i=String(t||"").trim();if(i&&e.includes(i))return i;let r=i.split(/[-_]/)[0]?.toLowerCase();if(r&&e.includes(r))return r;if(e.includes(n))return n;let a=e[0];if(!a)throw Error("A notification template needs copy in at least one language");return a}function $e(e,t){if(/^https?:\/\//i.test(t))return t;let n=String(e||"").replace(/\/+$/,"");if(!t)return n;return`${n}${t.startsWith("/")?"":"/"}${t}`}function ce(e,t={},n={}){let i=n.fallbackLocale||"en",r=se(Object.keys(e.copy),n.locale,i),a=e.copy[r],s={subject:w(a.subject,t),body:w(a.body,t)},o=e.action;if(o){let c=oe(o.path).some((m)=>t[m]==null||t[m]===""),l=o.label[r]??o.label[i]??Object.values(o.label)[0];if(!c&&l)s.action={label:l,url:$e(n.baseUrl,w(o.path,t))}}return s}function je(e,t={}){let n=Object.keys(e),i=()=>typeof t.baseUrl==="function"?t.baseUrl():t.baseUrl;return{templates:e,names:n,has:(r)=>Object.hasOwn(e,r),render(r,a={},s){let o=e[r];if(!o)throw Error(`Unknown notification template "${String(r)}"`);return ce(o,a,{locale:s,fallbackLocale:t.fallbackLocale,baseUrl:i()})}}}var Ie=Oe;function Be(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function St(e){if(!e.length)throw Error("No notification channels were delivered");let t=e.filter((n)=>!n.success);if(t.length){let n=t.map((i)=>i.error instanceof Error?i.error:Error(`Notification delivery failed through ${i.channel}`));if(n.length===1)throw n[0];throw AggregateError(n,n.map((i)=>i.message).join("; "))}}function D(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function le(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return j[t]}function fe(e){if(e)try{return ue.use(e)}catch(t){U.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return ue}function de(e){if(!e)return g;if(e!=="twilio"&&e!=="vonage")throw Error(`Unsupported SMS driver: ${e}`);let t=e;return{send:async(n)=>(await g.init(),g.getDriver(t).send(n))}}function Le(){return L}function Me(){return b}function Ct(){return p}function Fe(e,t){let n=e||Ie?.default||"email",i=t;switch(n){case"email":return fe(i);case"chat":return le(i);case"sms":return de(i);case"database":return Me();default:throw Error(`Notification type "${n}" is not supported`)}}async function Dt(e,t,n=["email"],i={}){let r=[...new Set(n)];if(e.userId&&!i.ignorePreferences)try{r=await C(e.userId,r,i.category)}catch(o){U.warn(`[notify] preferences filter failed, sending all channels: ${o.message}`)}let s=(await Promise.allSettled(r.map(async(o)=>{switch(o){case"email":{if(!e.email)throw Error("[notify] email channel requires recipient.email");let l=await fe().send(await _(e.email,t));Be(l);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(i.ignorePreferences!==!0){let{isPhoneOptedOut:l}=await import("@stacksjs/sms");if(await l(e.phone))throw Error("[notify] sms recipient has opted out (STOP)")}let c=de();D(await c.send({to:e.phone,body:v(t,i.category)}));break}case"chat":{if(!e.chatRecipient||Array.isArray(e.chatRecipient)&&!e.chatRecipient.length)throw Error("[notify] chat channel requires recipient.chatRecipient");let c=le();D(await c.send({to:e.chatRecipient,content:t.body,subject:t.subject,data:t.data}));break}case"database":{if(!e.userId)throw Error("[notify] database channel requires recipient.userId");await b.send({userId:e.userId,type:t.subject||"notification",data:{body:t.body,...t.data}});break}case"push":{if(!e.pushTokens||Array.isArray(e.pushTokens)&&e.pushTokens.length===0)throw Error("[notify] push channel requires recipient.pushTokens");let c=Le();D(await c.send(e.pushTokens,{title:t.subject,body:t.body,data:t.data},{driver:e.pushDriver}));break}case"broadcast":{let c=await p.send({channel:e.broadcastChannel,userId:e.userId,event:t.subject??"notification",data:{body:t.body,...t.data}});if(!c.delivered)throw Error(c.reason||"Broadcast notification was not delivered");break}default:throw Error(`Unsupported notification channel: ${o}`)}}))).map((o,c)=>{let l=r[c];if(!l)throw Error(`Missing notification channel for result ${c}`);if(o.status==="rejected"){let m=o.reason instanceof Error?o.reason.message:String(o.reason);U.warn(`[notify] ${l} channel failed: ${m}`)}return{channel:l,success:o.status==="fulfilled",error:o.status==="rejected"?o.reason instanceof Error?o.reason:Error(String(o.reason)):void 0}});return await Promise.all(s.map((o)=>P(k(e,t,o,i)))),s}function Ut(){return Fe()}export{p as BroadcastNotificationDriver,z as DEFAULT_SMS_OPT_OUT,h as DEFAULT_UNSUBSCRIBE_NOTICE,b as DatabaseNotificationDriver,W as SMS_SENDER_MAX_LENGTH,ne as accountNotificationTemplates,_e as bulkSetPreferences,ie as commerceNotificationTemplates,je as createNotificationTemplates,Ae as defineNotificationTemplates,q as emailParagraphs,Be as ensureSuccessfulEmailResult,St as ensureSuccessfulNotificationResults,C as filterChannelsByPreferences,E as getNotificationPreferences,w as interpolateNotificationText,H as listUnsubscribeHeaders,k as makeDeliveryRecord,Ut as notification,B as notificationChannel,Y as notificationEmailHtml,_ as notificationEmailMessage,V as notificationEmailText,v as notificationSmsBody,oe as notificationTemplateVariables,Ce as notificationUnsubscribeLinks,ee as notificationUnsubscribeToken,Dt as notify,te as parseNotificationUnsubscribeToken,P as recordNotificationDelivery,ce as renderNotificationTemplate,K as resolveDeliveryRecipient,se as resolveNotificationLocale,S as setNotificationPreference,G as smsSenderPrefix,re as subscriptionNotificationTemplates,De as unsubscribeTokenFromRequest,Ue as unsubscribeWithToken,Ct as useBroadcast,le as useChat,Me as useDatabase,fe as useEmail,Fe as useNotification,Le as usePush,de as useSMS,R as wherePreferenceCategory};
40
+ Dein Guthaben betr\xE4gt jetzt {{balance}}. Du kannst es bei deinem n\xE4chsten Einkauf verwenden.`}}}}}function H(e={}){let t=e.billing??"/billing";return{subscription_trial_ending:{copy:{en:{subject:"Your {{app}} trial ends on {{date}}",body:"Your {{plan}} trial ends on {{date}}. To keep using {{plan}}, check that your payment details are up to date. To stop, cancel before then and you won\u2019t be charged."},de:{subject:"Deine {{app}}-Testphase endet am {{date}}",body:"Deine Testphase f\xFCr {{plan}} endet am {{date}}. Wenn du {{plan}} weiter nutzen willst, pr\xFCfe, ob deine Zahlungsdaten aktuell sind. Wenn nicht, k\xFCndige vorher, dann wird nichts berechnet."}},action:{path:t,label:{en:"Manage billing",de:"Abrechnung verwalten"}}},subscription_payment_failed:{copy:{en:{subject:"Payment for {{plan}} failed",body:"We couldn\u2019t charge your payment method for your {{app}} {{plan}} plan. Update it to keep {{plan}} active."},de:{subject:"Zahlung f\xFCr {{plan}} fehlgeschlagen",body:"Wir konnten deine Zahlungsmethode f\xFCr deinen {{app}}-Tarif {{plan}} nicht belasten. Aktualisiere sie, damit {{plan}} aktiv bleibt."}},action:{path:t,label:{en:"Update payment method",de:"Zahlungsmethode aktualisieren"}}},subscription_ended:{copy:{en:{subject:"Your {{app}} {{plan}} plan has ended",body:"Your {{plan}} plan ended on {{date}}. Your account and data are still here; choose a plan again whenever you\u2019re ready."},de:{subject:"Dein {{app}}-Tarif {{plan}} ist beendet",body:"Dein Tarif {{plan}} endete am {{date}}. Dein Konto und deine Daten bleiben erhalten; du kannst jederzeit wieder einen Tarif w\xE4hlen."}},action:{path:t,label:{en:"Choose a plan",de:"Tarif w\xE4hlen"}}}}}var W=/\{\{\s*([a-z0-9_]+)\s*\}\}/gi;function xe(e){return e}function N(e,t={}){return e.replace(W,(n,i)=>{let r=t[i];return r==null?"":String(r)})}function J(e){return[...new Set([...e.matchAll(W)].map((t)=>t[1]))]}function w(e,t,n="en"){let i=String(t||"").trim();if(i&&e.includes(i))return i;let r=i.split(/[-_]/)[0]?.toLowerCase();if(r&&e.includes(r))return r;if(e.includes(n))return n;let a=e[0];if(!a)throw Error("A notification template needs copy in at least one language");return a}function ke(e,t){if(/^https?:\/\//i.test(t))return t;let n=String(e||"").replace(/\/+$/,"");if(!t)return n;return`${n}${t.startsWith("/")?"":"/"}${t}`}function Q(e,t={},n={}){let i=n.fallbackLocale||"en",r=w(Object.keys(e.copy),n.locale,i),a=e.copy[r],s={subject:N(a.subject,t),body:N(a.body,t)},o=e.action;if(o){let c=J(o.path).some((b)=>t[b]==null||t[b]===""),l=o.label[r]??o.label[i]??Object.values(o.label)[0];if(!c&&l)s.action={label:l,url:ke(n.baseUrl,N(o.path,t))}}return s}function Pe(e,t={}){let n=Object.keys(e),i=()=>typeof t.baseUrl==="function"?t.baseUrl():t.baseUrl;return{templates:e,names:n,has:(r)=>Object.hasOwn(e,r),render(r,a={},s){let o=e[r];if(!o)throw Error(`Unknown notification template "${String(r)}"`);return Q(o,a,{locale:s,fallbackLocale:t.fallbackLocale,baseUrl:i()})}}}var f=Object.freeze({en:Object.freeze({automatedNotice:"This is an automated message from {app}. Please do not reply to this email.",copyright:"{year} {app}. All rights reserved.",unsubscribeNotice:"You are receiving this because these emails are turned on in your settings.",unsubscribe:"Unsubscribe",preferences:"Email preferences",action:"Open",smsOptOut:"Reply STOP to opt out."}),de:Object.freeze({automatedNotice:"Diese Nachricht wurde automatisch von {app} verschickt. Bitte antworte nicht auf diese E-Mail.",copyright:"{year} {app}. Alle Rechte vorbehalten.",unsubscribeNotice:"Du erh\xE4ltst diese E-Mail, weil diese E-Mails in deinen Einstellungen aktiviert sind.",unsubscribe:"Abmelden",preferences:"E-Mail-Einstellungen",action:"\xD6ffnen",smsOptOut:"Antworte STOP zum Abbestellen."})});function p(e,t){let n=w(Object.keys(f),e,"en"),i={...f[n]};for(let[r,a]of Object.entries(t??{}))if(r in i&&typeof a==="string"&&a.trim())i[r]=a;return i}var _e=/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i;function E(e){let t=String(e??"").trim().replace(/_/g,"-");return _e.test(t)?t:"en"}var Ee=f.en.unsubscribeNotice,Se=f.en.smsOptOut,Re=/https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]]/g;function Z(e){return String(e??"").replace(/\r\n?/g,`
41
+ `).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let n=[];return t.split(`
42
+ `).forEach((i,r)=>{if(r>0)n.push({br:!0});let a=0;for(let s of i.matchAll(Re)){if(s.index>a)n.push({text:i.slice(a,s.index)});n.push({url:s[0]}),a=s.index+s[0].length}if(a<i.length)n.push({text:i.slice(a)})}),n})}function d(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t])}async function X(e){let t=e.unsubscribe?.url?e.unsubscribe:void 0,n=p(e.locale,e.strings);try{let{html:a}=await ve("notification",{subject:e.subject??"",variables:{subject:e.subject??"",paragraphs:Z(e.body),preheader:String(e.body??"").replace(/\s+/g," ").trim().slice(0,140),lang:E(e.locale),automatedNotice:n.automatedNotice,copyrightNotice:n.copyright,unsubscribeLabel:n.unsubscribe,preferencesLabel:n.preferences,...e.action?.url?{actionUrl:e.action.url,actionLabel:e.action.label||n.action}:{},...t?{unsubscribeUrl:t.url,unsubscribeNotice:t.notice||n.unsubscribeNotice,...t.preferencesUrl?{preferencesUrl:t.preferencesUrl}:{}}:{}}});if(a)return a}catch{}let i=e.action?.url?`<p><a href="${d(e.action.url)}">${d(e.action.label||e.action.url)}</a></p>`:"",r=t?`<p>${d(t.notice||n.unsubscribeNotice)} <a href="${d(t.url)}">${d(n.unsubscribe)}</a></p>`:"";return`<p>${d(e.body)}</p>${i}${r}`}function ee(e){let t=p(e.locale,e.strings),n=e.action?.url?`${e.body}
43
+
44
+ ${e.action.label||t.action}: ${e.action.url}`:e.body;if(e.unsubscribe?.url)n+=`
45
+
46
+ ${e.unsubscribe.notice||t.unsubscribeNotice}
47
+ ${t.unsubscribe}: ${e.unsubscribe.url}`;return n}function te(e){if(!e?.url)return{};return{"List-Unsubscribe":`<${e.oneClickUrl||e.url}>`,...e.oneClickUrl?{"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}:{}}}async function S(e,t){let n={...t.headers,...te(t.unsubscribe)};return{to:e,subject:t.subject??"",text:ee(t),html:await X(t),...Object.keys(n).length?{headers:n}:{}}}var ne=30;function ie(e){let t=String(e??"").replace(/\s+/g," ").trim().slice(0,ne).trim();return t?`${t}: `:""}function R(e,t){let n=String(e.body??""),i=ie(e.smsSender);if(i&&!n.trimStart().toLowerCase().startsWith(i.trim().toLowerCase()))n=`${i}${n.trimStart()}`;if(e.smsOptOut===!1)return n;let r=typeof e.smsOptOut==="string"?e.smsOptOut.trim():t==="marketing"?p(e.locale,e.strings).smsOptOut:"";if(!r||n.toLowerCase().includes(r.toLowerCase()))return n;return`${n.trimEnd()}
48
+
49
+ ${r}`}import{log as Ce}from"@stacksjs/cli";import{db as m,lockRow as re,sqlDateTime as De}from"@stacksjs/database/runtime";var T="notification_preferences";function D(e,t){return t===null?e.whereNull("category"):e.where("category","=",t)}async function C(e,t){let n=new Map;try{let r=await D(m.selectFrom(T).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let a of r)n.set(a.channel,a.enabled===!0||a.enabled===1)}catch(i){Ce.debug?.(`[notifications] preferences lookup failed (table missing?): ${i.message}`)}return n}async function A(e,t,n,i){await m.transaction(async(r)=>{await re(r,"users",{id:e}),await ae(e,t,n,i)})}async function ae(e,t,n,i){if(!Number.isSafeInteger(e)||e<=0||typeof n!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let r=De(),a=i??null,s=m.selectFrom(T).select(["id"]).where("user_id","=",e).where("channel","=",t),o=await D(s,a).executeTakeFirst();if(o?.id){await m.updateTable(T).set({enabled:n,updated_at:r}).where("id","=",o.id).execute();return}await m.insertInto(T).values({user_id:e,channel:t,enabled:n,category:a,created_at:r,updated_at:r}).execute()}async function Ae(e,t){await m.transaction(async(n)=>{await re(n,"users",{id:e});for(let i of t)await ae(e,i.channel,i.enabled,i.category)})}async function U(e,t,n){let i=await C(e,n);if(n!==void 0){let r=await C(e,void 0);for(let[a,s]of r)if(!s||!i.has(a))i.set(a,s)}return t.filter((r)=>{let a=i.get(r);return a===void 0?!0:a===!0})}import x from"process";import{createHmac as Ue,timingSafeEqual as Oe}from"crypto";import{db as $e}from"@stacksjs/database/runtime";var oe=["email","sms"];function je(){let e=x.env.APP_KEY;if((!e||e.length<16)&&(x.env.APP_ENV==="production"||x.env.NODE_ENV==="production"))throw Error("[notifications/unsubscribe] APP_KEY is missing or too short (need at least 16 characters). Cannot sign unsubscribe links.");return e||"stacks-default-key-dev-only-do-not-use-prod"}function se(e){return Ue("sha256",je()).update(e).digest("hex")}function ce(e,t,n="marketing"){if(!Number.isSafeInteger(e)||e<=0)throw Error("An unsubscribe token needs a positive user id");if(!oe.includes(t))throw Error(`Unsupported unsubscribe channel: ${t}`);if(!n||n.includes(":"))throw Error("An unsubscribe category must be non-empty and contain no colon");let i=`${e}:${t}:${n}`;return Buffer.from(`${i}:${se(i)}`).toString("base64url")}function ue(e){try{let t=Buffer.from(String(e??""),"base64url").toString("utf8");if(!e||Buffer.from(t).toString("base64url")!==e)return null;let n=t.split(":");if(n.length!==4)return null;let[i,r,a,s]=n;if(!i||!a||!s||!oe.includes(r))return null;let o=se(`${i}:${r}:${a}`);if(s.length!==o.length||!Oe(Buffer.from(s),Buffer.from(o)))return null;let c=Number(i);return Number.isSafeInteger(c)&&c>0&&String(c)===i?{userId:c,channel:r,category:a}:null}catch{return null}}function Ie(e,t={}){let n=String(t.baseUrl||x.env.APP_URL||"http://localhost").replace(/\/$/,""),i=ce(e,t.channel??"email",t.category??"marketing"),r=(a)=>a.startsWith("/")?a:`/${a}`;return{url:`${n}${r(t.pagePath??"/unsubscribe")}?token=${i}`,oneClickUrl:`${n}${r(t.endpointPath??"/api/notifications/unsubscribe")}?token=${i}`,...t.preferencesPath===!1?{}:{preferencesUrl:`${n}${r(t.preferencesPath??"/settings")}`},...t.notice?{notice:t.notice}:{}}}async function Le(e){let t=new URL(e.url,"http://localhost").searchParams.get("token");if(t)return t;let n=await e.json?.().catch(()=>({}));return String(n?.token||"")}async function Fe(e){let t=ue(e);if(!t)return null;if(await $e.selectFrom("users").select(["id"]).where("id","=",t.userId).executeTakeFirst())await A(t.userId,t.channel,!1,t.category);return t}var Me=Be;function ze(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function jt(e){if(!e.length)throw Error("No notification channels were delivered");let t=e.filter((n)=>!n.success);if(t.length){let n=t.map((i)=>i.error instanceof Error?i.error:Error(`Notification delivery failed through ${i.channel}`));if(n.length===1)throw n[0];throw AggregateError(n,n.map((i)=>i.message).join("; "))}}function O(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function fe(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return L[t]}function de(e){if(e)try{return le.use(e)}catch(t){j.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return le}function me(e){if(!e)return y;if(e!=="twilio"&&e!=="vonage")throw Error(`Unsupported SMS driver: ${e}`);let t=e;return{send:async(n)=>(await y.init(),y.getDriver(t).send(n))}}function Ke(){return z}function qe(){return h}function It(){return g}function Ye(e,t){let n=e||Me?.default||"email",i=t;switch(n){case"email":return de(i);case"chat":return fe(i);case"sms":return me(i);case"database":return qe();default:throw Error(`Notification type "${n}" is not supported`)}}async function Lt(e,t,n=["email"],i={}){let r=[...new Set(n)];if(e.userId&&!i.ignorePreferences)try{r=await U(e.userId,r,i.category)}catch(o){j.warn(`[notify] preferences filter failed, sending all channels: ${o.message}`)}let s=(await Promise.allSettled(r.map(async(o)=>{switch(o){case"email":{if(!e.email)throw Error("[notify] email channel requires recipient.email");let l=await de().send(await S(e.email,t));ze(l);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(i.ignorePreferences!==!0){let{isPhoneOptedOut:l}=await import("@stacksjs/sms");if(await l(e.phone))throw Error("[notify] sms recipient has opted out (STOP)")}let c=me();O(await c.send({to:e.phone,body:R(t,i.category)}));break}case"chat":{if(!e.chatRecipient||Array.isArray(e.chatRecipient)&&!e.chatRecipient.length)throw Error("[notify] chat channel requires recipient.chatRecipient");let c=fe();O(await c.send({to:e.chatRecipient,content:t.body,subject:t.subject,data:t.data}));break}case"database":{if(!e.userId)throw Error("[notify] database channel requires recipient.userId");await h.send({userId:e.userId,type:t.subject||"notification",data:{body:t.body,...t.data}});break}case"push":{if(!e.pushTokens||Array.isArray(e.pushTokens)&&e.pushTokens.length===0)throw Error("[notify] push channel requires recipient.pushTokens");let c=Ke();O(await c.send(e.pushTokens,{title:t.subject,body:t.body,data:t.data},{driver:e.pushDriver}));break}case"broadcast":{let c=await g.send({channel:e.broadcastChannel,userId:e.userId,event:t.subject??"notification",data:{body:t.body,...t.data}});if(!c.delivered)throw Error(c.reason||"Broadcast notification was not delivered");break}default:throw Error(`Unsupported notification channel: ${o}`)}}))).map((o,c)=>{let l=r[c];if(!l)throw Error(`Missing notification channel for result ${c}`);if(o.status==="rejected"){let b=o.reason instanceof Error?o.reason.message:String(o.reason);j.warn(`[notify] ${l} channel failed: ${b}`)}return{channel:l,success:o.status==="fulfilled",error:o.status==="rejected"?o.reason instanceof Error?o.reason:Error(String(o.reason)):void 0}});return await Promise.all(s.map((o)=>v(_(e,t,o,i)))),s}function Ft(){return Ye()}export{g as BroadcastNotificationDriver,Se as DEFAULT_SMS_OPT_OUT,Ee as DEFAULT_UNSUBSCRIBE_NOTICE,h as DatabaseNotificationDriver,f as NOTIFICATION_FRAME_STRINGS,ne as SMS_SENDER_MAX_LENGTH,V as accountNotificationTemplates,Ae as bulkSetPreferences,G as commerceNotificationTemplates,Pe as createNotificationTemplates,xe as defineNotificationTemplates,Z as emailParagraphs,ze as ensureSuccessfulEmailResult,jt as ensureSuccessfulNotificationResults,U as filterChannelsByPreferences,C as getNotificationPreferences,N as interpolateNotificationText,te as listUnsubscribeHeaders,_ as makeDeliveryRecord,Ft as notification,M as notificationChannel,X as notificationEmailHtml,S as notificationEmailMessage,ee as notificationEmailText,p as notificationFrameStrings,E as notificationLanguageTag,R as notificationSmsBody,J as notificationTemplateVariables,Ie as notificationUnsubscribeLinks,ce as notificationUnsubscribeToken,Lt as notify,ue as parseNotificationUnsubscribeToken,v as recordNotificationDelivery,Q as renderNotificationTemplate,Y as resolveDeliveryRecipient,w as resolveNotificationLocale,A as setNotificationPreference,ie as smsSenderPrefix,H as subscriptionNotificationTemplates,Le as unsubscribeTokenFromRequest,Fe as unsubscribeWithToken,It as useBroadcast,fe as useChat,qe as useDatabase,de as useEmail,Ye as useNotification,Ke as usePush,me as useSMS,D as wherePreferenceCategory};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/notifications",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.144",
5
+ "version": "0.75.146",
6
6
  "description": "The Stacks notifications integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -64,18 +64,18 @@
64
64
  "prepublishOnly": "bun run build"
65
65
  },
66
66
  "dependencies": {
67
- "@stacksjs/chat": "0.75.144",
68
- "@stacksjs/cli": "0.75.144",
69
- "@stacksjs/config": "0.75.144",
70
- "@stacksjs/database": "0.75.144",
71
- "@stacksjs/email": "0.75.144",
72
- "@stacksjs/push": "0.75.144",
73
- "@stacksjs/realtime": "0.75.144",
74
- "@stacksjs/sms": "0.75.144"
67
+ "@stacksjs/chat": "0.75.146",
68
+ "@stacksjs/cli": "0.75.146",
69
+ "@stacksjs/config": "0.75.146",
70
+ "@stacksjs/database": "0.75.146",
71
+ "@stacksjs/email": "0.75.146",
72
+ "@stacksjs/push": "0.75.146",
73
+ "@stacksjs/realtime": "0.75.146",
74
+ "@stacksjs/sms": "0.75.146"
75
75
  },
76
76
  "devDependencies": {
77
- "@stacksjs/error-handling": "0.75.144",
78
- "@stacksjs/types": "0.75.144",
77
+ "@stacksjs/error-handling": "0.75.146",
78
+ "@stacksjs/types": "0.75.146",
79
79
  "better-dx": "^0.2.24"
80
80
  }
81
81
  }