@stacksjs/notifications 0.75.136 → 0.75.138
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 +9 -0
- package/dist/email-body.d.ts +51 -3
- package/dist/index.d.ts +8 -3
- package/dist/index.js +9 -4
- package/dist/unsubscribe.d.ts +55 -0
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -53,6 +53,15 @@ The database inbox and `notification_deliveries` tracking log are separate.
|
|
|
53
53
|
Apply their model-driven migrations before relying on persistence. Inspect the
|
|
54
54
|
returned channel results; the tracking log is not a final receipt.
|
|
55
55
|
|
|
56
|
+
## Marketing unsubscribe
|
|
57
|
+
|
|
58
|
+
`payload.unsubscribe` (build it with `notificationUnsubscribeLinks(userId)`)
|
|
59
|
+
puts an Unsubscribe link in the email footer and text part and sets
|
|
60
|
+
`List-Unsubscribe` / `List-Unsubscribe-Post` (RFC 8058). A `marketing` text
|
|
61
|
+
ends with "Reply STOP to opt out." unless `payload.smsOptOut` says otherwise.
|
|
62
|
+
The signed token turns off one channel + category preference; handle the
|
|
63
|
+
app's route with `unsubscribeWithToken(await unsubscribeTokenFromRequest(request))`.
|
|
64
|
+
|
|
56
65
|
## Inbox ownership
|
|
57
66
|
|
|
58
67
|
```ts
|
package/dist/email-body.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EmailMessage } from '@stacksjs/types';
|
|
1
2
|
/**
|
|
2
3
|
* A plain-text notification body as the paragraphs of its email. Blank lines
|
|
3
4
|
* separate paragraphs, a single newline stays a line break, and an address is
|
|
@@ -10,15 +11,62 @@ export declare function emailParagraphs(body: string): EmailBodyPart[][];
|
|
|
10
11
|
* the same layout as every other Stacks email. It used to be the body in a
|
|
11
12
|
* bare `<p>`, which mail clients showed as unstyled text with dead links.
|
|
12
13
|
* Falls back to that only if the template cannot be rendered.
|
|
14
|
+
*
|
|
15
|
+
* With `unsubscribe`, the footer says why the email came and links to
|
|
16
|
+
* Unsubscribe (and the preferences page) instead of "do not reply".
|
|
13
17
|
*/
|
|
14
|
-
export declare function notificationEmailHtml(payload:
|
|
15
|
-
/** The plain-text part, with the action's link spelled out. */
|
|
16
|
-
export declare function notificationEmailText(payload: { body: string, action?: NotificationAction }): string;
|
|
18
|
+
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;
|
|
21
|
+
/**
|
|
22
|
+
* `List-Unsubscribe` for a payload's unsubscribe links (RFC 2369), plus
|
|
23
|
+
* `List-Unsubscribe-Post` when there is a one-click endpoint (RFC 8058).
|
|
24
|
+
* Empty without `unsubscribe`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function listUnsubscribeHeaders(unsubscribe?: NotificationUnsubscribe): Record<string, string>;
|
|
27
|
+
/**
|
|
28
|
+
* Everything `notify()` hands the mailer for one email: the text and HTML
|
|
29
|
+
* parts, and the payload's own headers with the unsubscribe ones on top.
|
|
30
|
+
*/
|
|
31
|
+
export declare function notificationEmailMessage(to: string, payload: NotificationEmailPayload): Promise<EmailMessage>;
|
|
32
|
+
/**
|
|
33
|
+
* What a notification texts. A `marketing` text ends with
|
|
34
|
+
* {@link DEFAULT_SMS_OPT_OUT} on its own line; `smsOptOut` replaces that line
|
|
35
|
+
* (and adds it to any category), and `false` leaves it out. A body that
|
|
36
|
+
* already contains the line is left alone.
|
|
37
|
+
*/
|
|
38
|
+
export declare function notificationSmsBody(payload: { body: string, smsOptOut?: string | false }, category?: string): string;
|
|
39
|
+
/** The footer sentence of an email with an unsubscribe link, unless the payload names its own. */
|
|
40
|
+
export declare const DEFAULT_UNSUBSCRIBE_NOTICE: 'You are receiving this because these emails are turned on in your settings.';
|
|
41
|
+
/**
|
|
42
|
+
* The line a marketing text ends with, unless the payload names its own
|
|
43
|
+
* (`smsOptOut`). Carriers expect opt-out instructions on marketing texts, and
|
|
44
|
+
* STOP is the keyword every provider and `@stacksjs/sms` honour.
|
|
45
|
+
*/
|
|
46
|
+
export declare const DEFAULT_SMS_OPT_OUT: 'Reply STOP to opt out.';
|
|
17
47
|
/** Where a notification leads: the email's button. */
|
|
18
48
|
export declare interface NotificationAction {
|
|
19
49
|
label: string
|
|
20
50
|
url: string
|
|
21
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The two ways out a marketing notification carries (see `notify()`'s
|
|
54
|
+
* `payload.unsubscribe`).
|
|
55
|
+
*/
|
|
56
|
+
export declare interface NotificationUnsubscribe {
|
|
57
|
+
url: string
|
|
58
|
+
oneClickUrl?: string
|
|
59
|
+
preferencesUrl?: string
|
|
60
|
+
notice?: string
|
|
61
|
+
}
|
|
62
|
+
/** What the email builders read from a notification payload. */
|
|
63
|
+
export declare interface NotificationEmailPayload {
|
|
64
|
+
subject?: string
|
|
65
|
+
body: string
|
|
66
|
+
action?: NotificationAction
|
|
67
|
+
unsubscribe?: NotificationUnsubscribe
|
|
68
|
+
headers?: Record<string, string>
|
|
69
|
+
}
|
|
22
70
|
/**
|
|
23
71
|
* One run of a notification email paragraph: text, a link, or a line break.
|
|
24
72
|
* A type, not an interface: template variables take index-signature types,
|
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,
|
|
6
|
+
import { type NotificationAction, 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 } from './email-body';
|
|
12
|
+
export type { EmailBodyPart, NotificationAction, NotificationEmailPayload, NotificationUnsubscribe } from './email-body';
|
|
13
|
+
export type { ParsedUnsubscribeToken, UnsubscribeChannel, UnsubscribeLinkOptions } from './unsubscribe';
|
|
13
14
|
export declare function ensureSuccessfulEmailResult(result: EmailResult): void;
|
|
14
15
|
/** Require at least one delivered channel and surface partial fan-out failure. */
|
|
15
16
|
export declare function ensureSuccessfulNotificationResults(results: readonly NotifyResult[]): void;
|
|
@@ -95,6 +96,9 @@ export declare interface NotificationPayload {
|
|
|
95
96
|
body: string
|
|
96
97
|
data?: Record<string, unknown>
|
|
97
98
|
action?: NotificationAction
|
|
99
|
+
unsubscribe?: NotificationUnsubscribe
|
|
100
|
+
headers?: Record<string, string>
|
|
101
|
+
smsOptOut?: string | false
|
|
98
102
|
}
|
|
99
103
|
export declare interface NotificationRecipient {
|
|
100
104
|
email?: string
|
|
@@ -127,4 +131,5 @@ export {
|
|
|
127
131
|
setNotificationPreference,
|
|
128
132
|
wherePreferenceCategory,
|
|
129
133
|
} from './preferences';
|
|
130
|
-
export { emailParagraphs, notificationEmailHtml, notificationEmailText } from './email-body';
|
|
134
|
+
export { DEFAULT_SMS_OPT_OUT, DEFAULT_UNSUBSCRIBE_NOTICE, emailParagraphs, listUnsubscribeHeaders, notificationEmailHtml, notificationEmailMessage, notificationEmailText, notificationSmsBody } from './email-body';
|
|
135
|
+
export { notificationUnsubscribeLinks, notificationUnsubscribeToken, parseNotificationUnsubscribeToken, unsubscribeTokenFromRequest, unsubscribeWithToken } from './unsubscribe';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import{log as _}from"@stacksjs/cli";import{notification as
|
|
3
|
-
`).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let
|
|
4
|
-
`).forEach((
|
|
2
|
+
import{log as _}from"@stacksjs/cli";import{notification as ve}from"@stacksjs/config";import{mail as X}from"@stacksjs/email";import{log as S}from"@stacksjs/cli";var m={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 r=await import("@stacksjs/realtime").catch(()=>null),i=r?.emit,s=r?.getServer;if(!i||!s)return{delivered:!1,channel:n,event:t,reason:"@stacksjs/realtime is not available"};if(!s())return{delivered:!1,channel:n,event:t,reason:"realtime server not running"};let a=e.private??e.userId!==void 0;return i(n,t,e.data,{private:a}),S.info(`[notifications] broadcast to '${n}' (${t})`),{delivered:!0,channel:n,event:t}}catch(r){let i=r instanceof Error?r.message:String(r);return S.warn(`[notifications] broadcast failed: ${i}`),{delivered:!1,channel:n,event:t,reason:i}}}};import*as D from"@stacksjs/chat";import{log as O}from"@stacksjs/cli";import{createHash as re}from"crypto";import{db as u,enqueueAfterCommit as ie,lockRow as se,sqlDateTime as w}from"@stacksjs/database/runtime";import{emit as ae,getServer as oe}from"@stacksjs/realtime";function I(e){return`private-user.${e}`}function N(e,t,n){let r=()=>{try{if(oe())ae(I(e),t,n)}catch{O.warn("Notification inbox broadcast failed; the persisted inbox remains available")}};if(!ie(r))r()}async function A(e,t){let n=w(),i=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(),s=Array.isArray(i)?i[0]:i,a=Number(s?.id??s?.insertId??s?.lastInsertRowid??s?.lastInsertId??0);if(!Number.isSafeInteger(a)||a<=0)throw Error("Database notification did not return a generated id");if(O.info(`Database notification sent to user ${e.userId}: ${e.type}`),e.broadcast!==!1)N(e.userId,"notifications.created",{id:a});return{id:a,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=re("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(r)=>{if(!await se(r,"users",{id:e.userId}))throw Error("Notification recipient not found");let s=await u.selectFrom("notifications").selectAll().where("user_id","=",e.userId).where("uuid","=",n).executeTakeFirst();if(s)return{...s,id:Number(s.id),user_id:Number(s.user_id)};return A(e,n)})}return A(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:w()}).where("id","=",e).whereNull("read_at");if(t!==void 0)n=n.where("user_id","=",t);if(await n.execute(),t)N(t,"notifications.read",{id:e})},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:w()}).where("user_id","=",e).whereNull("read_at").execute(),N(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 B from"@stacksjs/push";import*as h from"@stacksjs/sms";import{log as ce}from"@stacksjs/cli";import{config as j}from"@stacksjs/config";import{db as ue,sqlDateTime as L}from"@stacksjs/database/runtime";function M(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 x(e,t,n,r,i=L()){return{userId:e.userId,channel:n.channel,recipient:M(e,n.channel),subject:t.subject,body:t.body,status:n.success?"sent":"failed",error:n.error?.message,metadata:{...t.data,...r.category?{category:r.category}:{}},sentAt:i}}function fe(e){let t=j.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(!fe(e.channel))return;let t=L();try{await ue.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:j.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 r=n instanceof Error?n.message:String(n);ce.warn(`[notifications] could not record ${e.channel} delivery: ${r}`)}}import{templateByName as le}from"@stacksjs/email";var g="You are receiving this because these emails are turned on in your settings.",F="Reply STOP to opt out.",de=/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((r,i)=>{if(i>0)n.push({br:!0});let s=0;for(let a of r.matchAll(de)){if(a.index>s)n.push({text:r.slice(s,a.index)});n.push({url:a[0]}),s=a.index+a[0].length}if(s<r.length)n.push({text:r.slice(s)})}),n})}function d(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t])}async function H(e){let t=e.unsubscribe?.url?e.unsubscribe:void 0;try{let{html:i}=await le("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||g,...t.preferencesUrl?{preferencesUrl:t.preferencesUrl}:{}}:{}}});if(i)return i}catch{}let n=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||g)} <a href="${d(t.url)}">Unsubscribe</a></p>`:"";return`<p>${d(e.body)}</p>${n}${r}`}function K(e){let t=e.action?.url?`${e.body}
|
|
5
5
|
|
|
6
|
-
${e.action.label||"Open"}: ${e.action.url}`:e.body
|
|
6
|
+
${e.action.label||"Open"}: ${e.action.url}`:e.body;if(e.unsubscribe?.url)t+=`
|
|
7
|
+
|
|
8
|
+
${e.unsubscribe.notice||g}
|
|
9
|
+
Unsubscribe: ${e.unsubscribe.url}`;return t}function J(e){if(!e?.url)return{};return{"List-Unsubscribe":`<${e.oneClickUrl||e.url}>`,...e.oneClickUrl?{"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}:{}}}async function P(e,t){let n={...t.headers,...J(t.unsubscribe)};return{to:e,subject:t.subject??"",text:K(t),html:await H(t),...Object.keys(n).length?{headers:n}:{}}}function k(e,t){let n=String(e.body??"");if(e.smsOptOut===!1)return n;let r=typeof e.smsOptOut==="string"?e.smsOptOut.trim():t==="marketing"?F:"";if(!r||n.toLowerCase().includes(r.toLowerCase()))return n;return`${n.trimEnd()}
|
|
10
|
+
|
|
11
|
+
${r}`}import{log as me}from"@stacksjs/cli";import{db as l,lockRow as Q,sqlDateTime as be}from"@stacksjs/database/runtime";var p="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 i=await R(l.selectFrom(p).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let s of i)n.set(s.channel,s.enabled===!0||s.enabled===1)}catch(r){me.debug?.(`[notifications] preferences lookup failed (table missing?): ${r.message}`)}return n}async function U(e,t,n,r){await l.transaction(async(i)=>{await Q(i,"users",{id:e}),await W(e,t,n,r)})}async function W(e,t,n,r){if(!Number.isSafeInteger(e)||e<=0||typeof n!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let i=be(),s=r??null,a=l.selectFrom(p).select(["id"]).where("user_id","=",e).where("channel","=",t),o=await R(a,s).executeTakeFirst();if(o?.id){await l.updateTable(p).set({enabled:n,updated_at:i}).where("id","=",o.id).execute();return}await l.insertInto(p).values({user_id:e,channel:t,enabled:n,category:s,created_at:i,updated_at:i}).execute()}async function he(e,t){await l.transaction(async(n)=>{await Q(n,"users",{id:e});for(let r of t)await W(e,r.channel,r.enabled,r.category)})}async function T(e,t,n){let r=await E(e,n);if(n!==void 0){let i=await E(e,void 0);for(let[s,a]of i)if(!a||!r.has(s))r.set(s,a)}return t.filter((i)=>{let s=r.get(i);return s===void 0?!0:s===!0})}import y from"process";import{createHmac as ge,timingSafeEqual as pe}from"crypto";var Y=["email","sms"];function ye(){let e=y.env.APP_KEY;if((!e||e.length<16)&&(y.env.APP_ENV==="production"||y.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 V(e){return ge("sha256",ye()).update(e).digest("hex")}function z(e,t,n="marketing"){if(!Number.isSafeInteger(e)||e<=0)throw Error("An unsubscribe token needs a positive user id");if(!Y.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 r=`${e}:${t}:${n}`;return Buffer.from(`${r}:${V(r)}`).toString("base64url")}function G(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[r,i,s,a]=n;if(!r||!s||!a||!Y.includes(i))return null;let o=V(`${r}:${i}:${s}`);if(a.length!==o.length||!pe(Buffer.from(a),Buffer.from(o)))return null;let c=Number(r);return Number.isSafeInteger(c)&&c>0&&String(c)===r?{userId:c,channel:i,category:s}:null}catch{return null}}function we(e,t={}){let n=String(t.baseUrl||y.env.APP_URL||"http://localhost").replace(/\/$/,""),r=z(e,t.channel??"email",t.category??"marketing"),i=(s)=>s.startsWith("/")?s:`/${s}`;return{url:`${n}${i(t.pagePath??"/unsubscribe")}?token=${r}`,oneClickUrl:`${n}${i(t.endpointPath??"/api/notifications/unsubscribe")}?token=${r}`,...t.preferencesPath===!1?{}:{preferencesUrl:`${n}${i(t.preferencesPath??"/settings")}`},...t.notice?{notice:t.notice}:{}}}async function Ne(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 xe(e){let t=G(e);if(!t)return null;return await U(t.userId,t.channel,!1,t.category),t}var Pe=ve;function ke(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function ot(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((r)=>r.error instanceof Error?r.error:Error(`Notification delivery failed through ${r.channel}`));if(n.length===1)throw n[0];throw AggregateError(n,n.map((r)=>r.message).join("; "))}}function C(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function Z(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return D[t]}function ee(e){if(e)try{return X.use(e)}catch(t){_.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return X}function te(e){if(!e)return h;if(e!=="twilio"&&e!=="vonage")throw Error(`Unsupported SMS driver: ${e}`);let t=e;return{send:async(n)=>(await h.init(),h.getDriver(t).send(n))}}function Ee(){return B}function Re(){return b}function ct(){return m}function Ue(e,t){let n=e||Pe?.default||"email",r=t;switch(n){case"email":return ee(r);case"chat":return Z(r);case"sms":return te(r);case"database":return Re();default:throw Error(`Notification type "${n}" is not supported`)}}async function ut(e,t,n=["email"],r={}){let i=[...new Set(n)];if(e.userId&&!r.ignorePreferences)try{i=await T(e.userId,i,r.category)}catch(o){_.warn(`[notify] preferences filter failed, sending all channels: ${o.message}`)}let a=(await Promise.allSettled(i.map(async(o)=>{switch(o){case"email":{if(!e.email)throw Error("[notify] email channel requires recipient.email");let f=await ee().send(await P(e.email,t));ke(f);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(r.ignorePreferences!==!0){let{isPhoneOptedOut:f}=await import("@stacksjs/sms");if(await f(e.phone))throw Error("[notify] sms recipient has opted out (STOP)")}let c=te();C(await c.send({to:e.phone,body:k(t,r.category)}));break}case"chat":{if(!e.chatRecipient||Array.isArray(e.chatRecipient)&&!e.chatRecipient.length)throw Error("[notify] chat channel requires recipient.chatRecipient");let c=Z();C(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=Ee();C(await c.send(e.pushTokens,{title:t.subject,body:t.body,data:t.data},{driver:e.pushDriver}));break}case"broadcast":{let c=await m.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 f=i[c];if(!f)throw Error(`Missing notification channel for result ${c}`);if(o.status==="rejected"){let ne=o.reason instanceof Error?o.reason.message:String(o.reason);_.warn(`[notify] ${f} channel failed: ${ne}`)}return{channel:f,success:o.status==="fulfilled",error:o.status==="rejected"?o.reason instanceof Error?o.reason:Error(String(o.reason)):void 0}});return await Promise.all(a.map((o)=>v(x(e,t,o,r)))),a}function ft(){return Ue()}export{m as BroadcastNotificationDriver,F as DEFAULT_SMS_OPT_OUT,g as DEFAULT_UNSUBSCRIBE_NOTICE,b as DatabaseNotificationDriver,he as bulkSetPreferences,q as emailParagraphs,ke as ensureSuccessfulEmailResult,ot as ensureSuccessfulNotificationResults,T as filterChannelsByPreferences,E as getNotificationPreferences,J as listUnsubscribeHeaders,x as makeDeliveryRecord,ft as notification,I as notificationChannel,H as notificationEmailHtml,P as notificationEmailMessage,K as notificationEmailText,k as notificationSmsBody,we as notificationUnsubscribeLinks,z as notificationUnsubscribeToken,ut as notify,G as parseNotificationUnsubscribeToken,v as recordNotificationDelivery,M as resolveDeliveryRecipient,U as setNotificationPreference,Ne as unsubscribeTokenFromRequest,xe as unsubscribeWithToken,ct as useBroadcast,Z as useChat,Re as useDatabase,ee as useEmail,Ue as useNotification,Ee as usePush,te as useSMS,R as wherePreferenceCategory};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { NotificationUnsubscribe } from './email-body';
|
|
2
|
+
/** A signed token that turns off `category` on `channel` for `userId`. */
|
|
3
|
+
export declare function notificationUnsubscribeToken(userId: number, channel: UnsubscribeChannel, category?: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Verify a token from {@link notificationUnsubscribeToken}. Null when it is
|
|
6
|
+
* malformed, not canonical base64url, for an unsupported channel, or not
|
|
7
|
+
* signed with this app's key. Signatures are compared in constant time.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseNotificationUnsubscribeToken(token: string): ParsedUnsubscribeToken | null;
|
|
10
|
+
/**
|
|
11
|
+
* The `payload.unsubscribe` for one person: the page link (`?token=`), the
|
|
12
|
+
* one-click endpoint (`?token=`, so a mail client's form POST carries it),
|
|
13
|
+
* and the preferences page.
|
|
14
|
+
*/
|
|
15
|
+
export declare function notificationUnsubscribeLinks(userId: number, options?: UnsubscribeLinkOptions): NotificationUnsubscribe;
|
|
16
|
+
/**
|
|
17
|
+
* The token an unsubscribe request carries. A page posts `{ token }` as JSON;
|
|
18
|
+
* a mail client's one-click button POSTs `List-Unsubscribe=One-Click` as a
|
|
19
|
+
* form to the `List-Unsubscribe` URL, so there it is in the query string.
|
|
20
|
+
*/
|
|
21
|
+
export declare function unsubscribeTokenFromRequest(request: { url: string, json?: () => Promise<any> }): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Verify a token and turn its preference off. Returns what was turned off,
|
|
24
|
+
* or null for an invalid token (answer 422).
|
|
25
|
+
*/
|
|
26
|
+
export declare function unsubscribeWithToken(token: string): Promise<ParsedUnsubscribeToken | null>;
|
|
27
|
+
export declare interface ParsedUnsubscribeToken {
|
|
28
|
+
userId: number
|
|
29
|
+
channel: UnsubscribeChannel
|
|
30
|
+
category: string
|
|
31
|
+
}
|
|
32
|
+
export declare interface UnsubscribeLinkOptions {
|
|
33
|
+
channel?: UnsubscribeChannel
|
|
34
|
+
category?: string
|
|
35
|
+
baseUrl?: string
|
|
36
|
+
pagePath?: string
|
|
37
|
+
endpointPath?: string
|
|
38
|
+
preferencesPath?: string | false
|
|
39
|
+
notice?: string
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Signed unsubscribe links for notification preferences.
|
|
43
|
+
*
|
|
44
|
+
* `@stacksjs/email` has its own unsubscribe tokens, which suppress an
|
|
45
|
+
* address in `email_suppressions` for good. These are for the softer and
|
|
46
|
+
* more common case: one person turning off one category on one channel
|
|
47
|
+
* (marketing email, say), the same row a settings page writes. The token
|
|
48
|
+
* names the user, channel and category and is signed with `APP_KEY`, so a
|
|
49
|
+
* link can only ever switch off what it was minted for.
|
|
50
|
+
*
|
|
51
|
+
* The format is `base64url("{userId}:{channel}:{category}:{hmac-sha256 hex}")`.
|
|
52
|
+
* It does not expire: an archived marketing email must still be able to
|
|
53
|
+
* unsubscribe, and the worst a leaked link can do is stop marketing.
|
|
54
|
+
*/
|
|
55
|
+
export type UnsubscribeChannel = 'email' | 'sms';
|
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.
|
|
5
|
+
"version": "0.75.138",
|
|
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.
|
|
68
|
-
"@stacksjs/cli": "0.75.
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/database": "0.75.
|
|
71
|
-
"@stacksjs/email": "0.75.
|
|
72
|
-
"@stacksjs/push": "0.75.
|
|
73
|
-
"@stacksjs/realtime": "0.75.
|
|
74
|
-
"@stacksjs/sms": "0.75.
|
|
67
|
+
"@stacksjs/chat": "0.75.138",
|
|
68
|
+
"@stacksjs/cli": "0.75.138",
|
|
69
|
+
"@stacksjs/config": "0.75.138",
|
|
70
|
+
"@stacksjs/database": "0.75.138",
|
|
71
|
+
"@stacksjs/email": "0.75.138",
|
|
72
|
+
"@stacksjs/push": "0.75.138",
|
|
73
|
+
"@stacksjs/realtime": "0.75.138",
|
|
74
|
+
"@stacksjs/sms": "0.75.138"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@stacksjs/error-handling": "0.75.
|
|
78
|
-
"@stacksjs/types": "0.75.
|
|
77
|
+
"@stacksjs/error-handling": "0.75.138",
|
|
78
|
+
"@stacksjs/types": "0.75.138",
|
|
79
79
|
"better-dx": "^0.2.24"
|
|
80
80
|
}
|
|
81
81
|
}
|