@stacksjs/notifications 0.75.137 → 0.75.139
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 +50 -0
- package/dist/email-body.d.ts +51 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +47 -4
- package/dist/template-catalogs/account.d.ts +21 -0
- package/dist/template-catalogs/commerce.d.ts +21 -0
- package/dist/template-catalogs/subscription.d.ts +14 -0
- package/dist/templates.d.ts +98 -0
- package/dist/templates.js +40 -0
- package/dist/unsubscribe.d.ts +55 -0
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -53,6 +53,56 @@ 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
|
+
|
|
65
|
+
## Templates
|
|
66
|
+
|
|
67
|
+
`@stacksjs/notifications/templates` (also re-exported from the package root)
|
|
68
|
+
keeps an app's messages in one catalog: each named message has its copy per
|
|
69
|
+
language and, optionally, the email's button. It is pure and browser-safe.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { accountNotificationTemplates, createNotificationTemplates, defineNotificationTemplates } from '@stacksjs/notifications/templates'
|
|
73
|
+
|
|
74
|
+
const templates = createNotificationTemplates({
|
|
75
|
+
...accountNotificationTemplates({ resetPassword: '/forgot-password' }),
|
|
76
|
+
...defineNotificationTemplates({
|
|
77
|
+
class_booked: {
|
|
78
|
+
copy: {
|
|
79
|
+
en: { subject: 'You’re booked at {{gym}}', body: 'See you at {{class_name}} on {{date}}.' },
|
|
80
|
+
de: { subject: 'Gebucht bei {{gym}}', body: 'Bis {{date}} bei {{class_name}}.' },
|
|
81
|
+
},
|
|
82
|
+
action: { path: '/schedule', label: { en: 'View your schedule', de: 'Zum Stundenplan' } },
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
}, { baseUrl: () => process.env.APP_URL || '' })
|
|
86
|
+
|
|
87
|
+
await notify({ email }, templates.render('class_booked', { gym, class_name, date }, user.locale), ['email'])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`{{name}}` is replaced by that variable, a missing one by nothing. A button
|
|
91
|
+
whose path needs a variable that was not given is left out. A path that renders
|
|
92
|
+
to an absolute URL (a one-time link) is used as it is; any other is joined to
|
|
93
|
+
`baseUrl`. `de-AT` falls back to `de`, then to `fallbackLocale` (`en`).
|
|
94
|
+
|
|
95
|
+
Built-in catalogs, in English and German, take the paths their buttons lead to:
|
|
96
|
+
|
|
97
|
+
| Catalog | Messages |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `accountNotificationTemplates()` | `password_changed`, `email_changed` (send to both addresses), `account_deleted`, `account_created` (someone made the account; set-password link) |
|
|
100
|
+
| `commerceNotificationTemplates()` | `order_receipt`, `order_refunded`, `new_sale` (to the seller), `account_credit_added` |
|
|
101
|
+
| `subscriptionNotificationTemplates()` | `subscription_trial_ending`, `subscription_payment_failed`, `subscription_ended` |
|
|
102
|
+
|
|
103
|
+
Each documents its variables. Format amounts first: `receiptLinesText()` from
|
|
104
|
+
`@stacksjs/commerce/receipt-text` builds the receipt's `lines`.
|
|
105
|
+
|
|
56
106
|
## Inbox ownership
|
|
57
107
|
|
|
58
108
|
```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,15 @@ 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';
|
|
14
|
+
export type { AccountNotificationPaths, AccountNotificationTemplateName, CommerceNotificationPaths, CommerceNotificationTemplateName, NotificationTemplateAction, NotificationTemplateCatalog, NotificationTemplateCatalogOptions, NotificationTemplateCopy, NotificationTemplateDefinition, NotificationTemplateVariables, RenderedNotificationTemplate, RenderNotificationTemplateOptions, SubscriptionNotificationPaths, SubscriptionNotificationTemplateName } from './templates';
|
|
13
15
|
export declare function ensureSuccessfulEmailResult(result: EmailResult): void;
|
|
14
16
|
/** Require at least one delivered channel and surface partial fan-out failure. */
|
|
15
17
|
export declare function ensureSuccessfulNotificationResults(results: readonly NotifyResult[]): void;
|
|
@@ -95,6 +97,9 @@ export declare interface NotificationPayload {
|
|
|
95
97
|
body: string
|
|
96
98
|
data?: Record<string, unknown>
|
|
97
99
|
action?: NotificationAction
|
|
100
|
+
unsubscribe?: NotificationUnsubscribe
|
|
101
|
+
headers?: Record<string, string>
|
|
102
|
+
smsOptOut?: string | false
|
|
98
103
|
}
|
|
99
104
|
export declare interface NotificationRecipient {
|
|
100
105
|
email?: string
|
|
@@ -127,4 +132,6 @@ export {
|
|
|
127
132
|
setNotificationPreference,
|
|
128
133
|
wherePreferenceCategory,
|
|
129
134
|
} from './preferences';
|
|
130
|
-
export { emailParagraphs, notificationEmailHtml, notificationEmailText } from './email-body';
|
|
135
|
+
export { DEFAULT_SMS_OPT_OUT, DEFAULT_UNSUBSCRIBE_NOTICE, emailParagraphs, listUnsubscribeHeaders, notificationEmailHtml, notificationEmailMessage, notificationEmailText, notificationSmsBody } from './email-body';
|
|
136
|
+
export { notificationUnsubscribeLinks, notificationUnsubscribeToken, parseNotificationUnsubscribeToken, unsubscribeTokenFromRequest, unsubscribeWithToken } from './unsubscribe';
|
|
137
|
+
export { accountNotificationTemplates, commerceNotificationTemplates, createNotificationTemplates, defineNotificationTemplates, interpolateNotificationText, notificationTemplateVariables, renderNotificationTemplate, resolveNotificationLocale, subscriptionNotificationTemplates } from './templates';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import{log as
|
|
3
|
-
`).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let
|
|
4
|
-
`).forEach((
|
|
2
|
+
import{log as S}from"@stacksjs/cli";import{notification as Ae}from"@stacksjs/config";import{mail as se}from"@stacksjs/email";import{log as A}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 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 fe}from"crypto";import{db as u,enqueueAfterCommit as de,lockRow as pe,sqlDateTime as T}from"@stacksjs/database/runtime";import{emit as me,getServer as be}from"@stacksjs/realtime";function B(e){return`private-user.${e}`}function x(e,t,n){let i=()=>{try{if(be())me(B(e),t,n)}catch{I.warn("Notification inbox broadcast failed; the persisted inbox remains available")}};if(!de(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=fe("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 pe(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 h from"@stacksjs/sms";import{log as he}from"@stacksjs/cli";import{config as M}from"@stacksjs/config";import{db as ge,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 ye(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(!ye(e.channel))return;let t=F();try{await ge.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);he.warn(`[notifications] could not record ${e.channel} delivery: ${i}`)}}import{templateByName as Ne}from"@stacksjs/email";var g="You are receiving this because these emails are turned on in your settings.",z="Reply STOP to opt out.",we=/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(we)){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)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t])}async function Y(e){let t=e.unsubscribe?.url?e.unsubscribe:void 0;try{let{html:r}=await Ne("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(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||g)} <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
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 W(e){if(!e?.url)return{};return{"List-Unsubscribe":`<${e.oneClickUrl||e.url}>`,...e.oneClickUrl?{"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}:{}}}async function v(e,t){let n={...t.headers,...W(t.unsubscribe)};return{to:e,subject:t.subject??"",text:V(t),html:await Y(t),...Object.keys(n).length?{headers:n}:{}}}function _(e,t){let n=String(e.body??"");if(e.smsOptOut===!1)return n;let i=typeof e.smsOptOut==="string"?e.smsOptOut.trim():t==="marketing"?z:"";if(!i||n.toLowerCase().includes(i.toLowerCase()))return n;return`${n.trimEnd()}
|
|
10
|
+
|
|
11
|
+
${i}`}import{log as Te}from"@stacksjs/cli";import{db as f,lockRow as H,sqlDateTime as xe}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){Te.debug?.(`[notifications] preferences lookup failed (table missing?): ${i.message}`)}return n}async function C(e,t,n,i){await f.transaction(async(r)=>{await H(r,"users",{id:e}),await J(e,t,n,i)})}async function J(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=xe(),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 ke(e,t){await f.transaction(async(n)=>{await H(n,"users",{id:e});for(let i of t)await J(e,i.channel,i.enabled,i.category)})}async function D(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 Pe,timingSafeEqual as ve}from"crypto";var Q=["email","sms"];function _e(){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 Z(e){return Pe("sha256",_e()).update(e).digest("hex")}function G(e,t,n="marketing"){if(!Number.isSafeInteger(e)||e<=0)throw Error("An unsubscribe token needs a positive user id");if(!Q.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}:${Z(i)}`).toString("base64url")}function X(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||!Q.includes(r))return null;let o=Z(`${i}:${r}:${a}`);if(s.length!==o.length||!ve(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 Ee(e,t={}){let n=String(t.baseUrl||N.env.APP_URL||"http://localhost").replace(/\/$/,""),i=G(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 Re(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 Ce(e){let t=X(e);if(!t)return null;return await C(t.userId,t.channel,!1,t.category),t}function ee(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
|
+
|
|
13
|
+
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
|
+
|
|
15
|
+
Warst du das, musst du nichts weiter tun. Falls nicht, setze dein Passwort sofort zur\xFCck und gib uns unter {{support_email}} Bescheid.`}},action:{path:t,label:{en:"Reset your password",de:"Passwort zur\xFCcksetzen"}}},email_changed:{copy:{en:{subject:"Your {{app}} sign-in email was changed",body:`The email address for your {{app}} account was changed from {{old_email}} to {{new_email}}. From now on, you sign in and get email at {{new_email}}.
|
|
16
|
+
|
|
17
|
+
If you didn\u2019t make this change, write to us at {{support_email}} straight away.`},de:{subject:"Deine Anmelde-E-Mail bei {{app}} wurde ge\xE4ndert",body:`Die E-Mail-Adresse deines {{app}}-Kontos wurde von {{old_email}} zu {{new_email}} ge\xE4ndert. Ab jetzt meldest du dich mit {{new_email}} an und bekommst dort deine E-Mails.
|
|
18
|
+
|
|
19
|
+
Warst du das nicht, schreib uns sofort an {{support_email}}.`}}},account_deleted:{copy:{en:{subject:"Your {{app}} account is deleted",body:`Your {{app}} account was deleted on {{date}}. This is the last email we\u2019ll send to this address.
|
|
20
|
+
|
|
21
|
+
If you didn\u2019t ask for this, write to us at {{support_email}}.`},de:{subject:"Dein {{app}}-Konto ist gel\xF6scht",body:`Dein {{app}}-Konto wurde am {{date}} gel\xF6scht. Das ist die letzte E-Mail, die wir an diese Adresse schicken.
|
|
22
|
+
|
|
23
|
+
Hast du das nicht veranlasst, schreib uns an {{support_email}}.`}}},account_created:{copy:{en:{subject:"{{inviter}} added you to {{app}}",body:`{{inviter}} created an {{app}} account for you with this email address.
|
|
24
|
+
|
|
25
|
+
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
|
+
|
|
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 te(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
|
+
|
|
29
|
+
{{lines}}
|
|
30
|
+
Total: {{total}}
|
|
31
|
+
|
|
32
|
+
Order {{order_number}}, paid {{date}}.`},de:{subject:"Deine Quittung von {{seller}}",body:`Danke f\xFCr deinen Einkauf bei {{seller}}.
|
|
33
|
+
|
|
34
|
+
{{lines}}
|
|
35
|
+
Summe: {{total}}
|
|
36
|
+
|
|
37
|
+
Bestellung {{order_number}}, bezahlt am {{date}}.`}},action:{path:t,label:{en:"View your purchases",de:"Deine K\xE4ufe ansehen"}}},order_refunded:{copy:{en:{subject:"Refund from {{seller}}: {{amount}}",body:`{{seller}} refunded {{amount}} for order {{order_number}}.
|
|
38
|
+
|
|
39
|
+
Money paid by card goes back to that card, which usually takes 5 to 10 business days to show. Anything refunded as account credit is ready for your next purchase.`},de:{subject:"Erstattung von {{seller}}: {{amount}}",body:`{{seller}} hat dir {{amount}} f\xFCr die Bestellung {{order_number}} erstattet.
|
|
40
|
+
|
|
41
|
+
Per Karte Bezahltes geht auf diese Karte zur\xFCck, was meist 5 bis 10 Werktage dauert. Als Guthaben Erstattetes kannst du bei deinem n\xE4chsten Einkauf verwenden.`}},action:{path:t,label:{en:"View your purchases",de:"Deine K\xE4ufe ansehen"}}},new_sale:{copy:{en:{subject:"New sale: {{item}}",body:`{{buyer}} bought {{item}} for {{total}}.
|
|
42
|
+
|
|
43
|
+
Order {{order_number}}.`},de:{subject:"Neuer Verkauf: {{item}}",body:`{{buyer}} hat {{item}} f\xFCr {{total}} gekauft.
|
|
44
|
+
|
|
45
|
+
Bestellung {{order_number}}.`}},action:{path:n,label:{en:"View your sales",de:"Verk\xE4ufe ansehen"}}},account_credit_added:{copy:{en:{subject:"{{amount}} credit from {{seller}}",body:`{{seller}} added {{amount}} to your account: {{reason}}
|
|
46
|
+
|
|
47
|
+
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
|
+
|
|
49
|
+
Dein Guthaben betr\xE4gt jetzt {{balance}}. Du kannst es bei deinem n\xE4chsten Einkauf verwenden.`}}}}}function ne(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 ie=/\{\{\s*([a-z0-9_]+)\s*\}\}/gi;function De(e){return e}function w(e,t={}){return e.replace(ie,(n,i)=>{let r=t[i];return r==null?"":String(r)})}function re(e){return[...new Set([...e.matchAll(ie)].map((t)=>t[1]))]}function ae(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 Ue(e,t){if(/^https?:\/\//i.test(t))return t;let n=String(e||"").replace(/\/+$/,"");if(!t)return n;return`${n}${t.startsWith("/")?"":"/"}${t}`}function oe(e,t={},n={}){let i=n.fallbackLocale||"en",r=ae(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=re(o.path).some((p)=>t[p]==null||t[p]===""),l=o.label[r]??o.label[i]??Object.values(o.label)[0];if(!c&&l)s.action={label:l,url:Ue(n.baseUrl,w(o.path,t))}}return s}function Se(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 oe(o,a,{locale:s,fallbackLocale:t.fallbackLocale,baseUrl:i()})}}}var $e=Ae;function je(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function vt(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 U(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function ce(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return j[t]}function ue(e){if(e)try{return se.use(e)}catch(t){S.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return se}function le(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 Oe(){return L}function Ie(){return b}function _t(){return m}function Be(e,t){let n=e||$e?.default||"email",i=t;switch(n){case"email":return ue(i);case"chat":return ce(i);case"sms":return le(i);case"database":return Ie();default:throw Error(`Notification type "${n}" is not supported`)}}async function Et(e,t,n=["email"],i={}){let r=[...new Set(n)];if(e.userId&&!i.ignorePreferences)try{r=await D(e.userId,r,i.category)}catch(o){S.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 ue().send(await v(e.email,t));je(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=le();U(await c.send({to:e.phone,body:_(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=ce();U(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=Oe();U(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 l=r[c];if(!l)throw Error(`Missing notification channel for result ${c}`);if(o.status==="rejected"){let p=o.reason instanceof Error?o.reason.message:String(o.reason);S.warn(`[notify] ${l} channel failed: ${p}`)}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 Rt(){return Be()}export{m as BroadcastNotificationDriver,z as DEFAULT_SMS_OPT_OUT,g as DEFAULT_UNSUBSCRIBE_NOTICE,b as DatabaseNotificationDriver,ee as accountNotificationTemplates,ke as bulkSetPreferences,te as commerceNotificationTemplates,Se as createNotificationTemplates,De as defineNotificationTemplates,q as emailParagraphs,je as ensureSuccessfulEmailResult,vt as ensureSuccessfulNotificationResults,D as filterChannelsByPreferences,E as getNotificationPreferences,w as interpolateNotificationText,W as listUnsubscribeHeaders,k as makeDeliveryRecord,Rt as notification,B as notificationChannel,Y as notificationEmailHtml,v as notificationEmailMessage,V as notificationEmailText,_ as notificationSmsBody,re as notificationTemplateVariables,Ee as notificationUnsubscribeLinks,G as notificationUnsubscribeToken,Et as notify,X as parseNotificationUnsubscribeToken,P as recordNotificationDelivery,oe as renderNotificationTemplate,K as resolveDeliveryRecipient,ae as resolveNotificationLocale,C as setNotificationPreference,ne as subscriptionNotificationTemplates,Re as unsubscribeTokenFromRequest,Ce as unsubscribeWithToken,_t as useBroadcast,ce as useChat,Ie as useDatabase,ue as useEmail,Be as useNotification,Oe as usePush,le as useSMS,R as wherePreferenceCategory};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NotificationTemplateDefinition } from '../templates';
|
|
2
|
+
export declare function accountNotificationTemplates(paths?: AccountNotificationPaths): Record<AccountNotificationTemplateName, NotificationTemplateDefinition>;
|
|
3
|
+
/** Where the account messages' buttons lead. Defaults are the framework's own pages. */
|
|
4
|
+
export declare interface AccountNotificationPaths {
|
|
5
|
+
resetPassword?: string
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Account security messages, in English and German. Every one is
|
|
9
|
+
* transactional: send it whatever the person's preferences say.
|
|
10
|
+
*
|
|
11
|
+
* Variables: `app` (the app's name) and `support_email` in all of them, plus
|
|
12
|
+
* - `password_changed`: none;
|
|
13
|
+
* - `email_changed`: `old_email`, `new_email`. Send it to both addresses: the
|
|
14
|
+
* old one is how the owner learns of a change they did not make;
|
|
15
|
+
* - `account_deleted`: `date`. Send it before the address is erased;
|
|
16
|
+
* - `account_created`: `inviter` (who created it), `set_password_url` (a
|
|
17
|
+
* one-time link, so send it outside any stored queue) and `days` (how long
|
|
18
|
+
* that link works; two or more).
|
|
19
|
+
*/
|
|
20
|
+
/** The names this catalog defines. */
|
|
21
|
+
export type AccountNotificationTemplateName = 'password_changed' | 'email_changed' | 'account_deleted' | 'account_created';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NotificationTemplateDefinition } from '../templates';
|
|
2
|
+
export declare function commerceNotificationTemplates(paths?: CommerceNotificationPaths): Record<CommerceNotificationTemplateName, NotificationTemplateDefinition>;
|
|
3
|
+
/** Where the commerce messages' buttons lead. */
|
|
4
|
+
export declare interface CommerceNotificationPaths {
|
|
5
|
+
orders?: string
|
|
6
|
+
sales?: string
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Receipts and money messages, in English and German. Format every amount
|
|
10
|
+
* before passing it (`formatMinor` from `@stacksjs/commerce/money`), so the
|
|
11
|
+
* currency is right; `lines` is the itemized purchase, one line each
|
|
12
|
+
* (`receiptLinesText` from `@stacksjs/commerce/receipt-text`).
|
|
13
|
+
*
|
|
14
|
+
* Variables:
|
|
15
|
+
* - `order_receipt` (to the buyer): `seller`, `lines`, `total`, `order_number`, `date`;
|
|
16
|
+
* - `order_refunded` (to the buyer): `seller`, `amount`, `order_number`;
|
|
17
|
+
* - `new_sale` (to the seller): `buyer`, `item`, `total`, `order_number`;
|
|
18
|
+
* - `account_credit_added` (to the customer): `seller`, `amount`, `reason`, `balance`.
|
|
19
|
+
*/
|
|
20
|
+
/** The names this catalog defines. */
|
|
21
|
+
export type CommerceNotificationTemplateName = 'order_receipt' | 'order_refunded' | 'new_sale' | 'account_credit_added';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NotificationTemplateDefinition } from '../templates';
|
|
2
|
+
export declare function subscriptionNotificationTemplates(paths?: SubscriptionNotificationPaths): Record<SubscriptionNotificationTemplateName, NotificationTemplateDefinition>;
|
|
3
|
+
/** Where the subscription messages' buttons lead. */
|
|
4
|
+
export declare interface SubscriptionNotificationPaths {
|
|
5
|
+
billing?: string
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A subscriber's own plan with the app (SaaS billing), in English and German.
|
|
9
|
+
*
|
|
10
|
+
* Variables: `app`, `plan` (its name), plus `date` for `subscription_trial_ending`
|
|
11
|
+
* (the last day of the trial) and `subscription_ended` (when it ended).
|
|
12
|
+
*/
|
|
13
|
+
/** The names this catalog defines. */
|
|
14
|
+
export type SubscriptionNotificationTemplateName = 'subscription_trial_ending' | 'subscription_payment_failed' | 'subscription_ended';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declare a catalog. Returns it unchanged; the point is the checked shape and
|
|
3
|
+
* the literal names, so `render('nmae')` is a type error.
|
|
4
|
+
*/
|
|
5
|
+
export declare function defineNotificationTemplates<const T extends Record<string, NotificationTemplateDefinition>>(templates: T): T;
|
|
6
|
+
/** Replace every `{{name}}` with that variable; a missing or null one becomes empty. */
|
|
7
|
+
export declare function interpolateNotificationText(text: string, variables?: NotificationTemplateVariables): string;
|
|
8
|
+
/** The variables a text refers to, in order of first use. */
|
|
9
|
+
export declare function notificationTemplateVariables(text: string): string[];
|
|
10
|
+
/** The language a template is rendered in: exact, then the base language, then the fallback, then its first. */
|
|
11
|
+
export declare function resolveNotificationLocale(available: readonly string[], locale?: string | null, fallbackLocale?: string): string;
|
|
12
|
+
/** Render one template definition for one recipient. */
|
|
13
|
+
export declare function renderNotificationTemplate(definition: NotificationTemplateDefinition, variables?: NotificationTemplateVariables, options?: RenderNotificationTemplateOptions): RenderedNotificationTemplate;
|
|
14
|
+
/** A catalog bound to the app's URL and fallback language. */
|
|
15
|
+
export declare function createNotificationTemplates<const T extends Record<string, NotificationTemplateDefinition>>(templates: T, options?: NotificationTemplateCatalogOptions): NotificationTemplateCatalog<T>;
|
|
16
|
+
/** One language's copy of a message. */
|
|
17
|
+
export declare interface NotificationTemplateCopy {
|
|
18
|
+
subject: string
|
|
19
|
+
body: string
|
|
20
|
+
}
|
|
21
|
+
/** The email's button: where it leads (a path or URL) and its label per language. */
|
|
22
|
+
export declare interface NotificationTemplateAction {
|
|
23
|
+
path: string
|
|
24
|
+
label: Record<string, string>
|
|
25
|
+
}
|
|
26
|
+
/** A named message: its copy per language and, optionally, a button. */
|
|
27
|
+
export declare interface NotificationTemplateDefinition {
|
|
28
|
+
copy: Record<string, NotificationTemplateCopy>
|
|
29
|
+
action?: NotificationTemplateAction
|
|
30
|
+
}
|
|
31
|
+
/** What a template renders to: the payload `notify()` takes. */
|
|
32
|
+
export declare interface RenderedNotificationTemplate {
|
|
33
|
+
subject: string
|
|
34
|
+
body: string
|
|
35
|
+
action?: { label: string, url: string }
|
|
36
|
+
}
|
|
37
|
+
export declare interface RenderNotificationTemplateOptions {
|
|
38
|
+
locale?: string | null
|
|
39
|
+
fallbackLocale?: string
|
|
40
|
+
baseUrl?: string
|
|
41
|
+
}
|
|
42
|
+
export declare interface NotificationTemplateCatalogOptions {
|
|
43
|
+
baseUrl?: string | (() => string)
|
|
44
|
+
fallbackLocale?: string
|
|
45
|
+
}
|
|
46
|
+
export declare interface NotificationTemplateCatalog<T extends Record<string, NotificationTemplateDefinition>> {
|
|
47
|
+
readonly templates: T
|
|
48
|
+
readonly names: Array<keyof T & string>
|
|
49
|
+
has: (name: string) => name is keyof T & string
|
|
50
|
+
render: (name: keyof T & string, variables?: NotificationTemplateVariables, locale?: string | null) => RenderedNotificationTemplate
|
|
51
|
+
}
|
|
52
|
+
export type NotificationTemplateVariables = Record<string, unknown>;
|
|
53
|
+
/**
|
|
54
|
+
* Localized notification templates: a catalog of named messages, each with
|
|
55
|
+
* its copy in every language the app speaks and the email's button, rendered
|
|
56
|
+
* into the `{ subject, body, action }` that `notify()` sends.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { accountNotificationTemplates, createNotificationTemplates, defineNotificationTemplates } from '@stacksjs/notifications/templates'
|
|
60
|
+
*
|
|
61
|
+
* const templates = createNotificationTemplates({
|
|
62
|
+
* ...accountNotificationTemplates(),
|
|
63
|
+
* ...defineNotificationTemplates({
|
|
64
|
+
* class_booked: {
|
|
65
|
+
* copy: {
|
|
66
|
+
* en: { subject: 'You’re booked', body: 'See you at {{class_name}} on {{date}}.' },
|
|
67
|
+
* de: { subject: 'Du bist gebucht', body: 'Bis {{date}} bei {{class_name}}.' },
|
|
68
|
+
* },
|
|
69
|
+
* action: { path: '/schedule', label: { en: 'View your schedule', de: 'Zum Stundenplan' } },
|
|
70
|
+
* },
|
|
71
|
+
* }),
|
|
72
|
+
* }, { baseUrl: 'https://example.com' })
|
|
73
|
+
*
|
|
74
|
+
* templates.render('class_booked', { class_name: 'Strength', date: '2026-07-20' }, 'de')
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* `{{name}}` in a subject, body or button path is replaced by that variable
|
|
78
|
+
* (a missing one by nothing). A button whose path needs a variable that was
|
|
79
|
+
* not given is left out rather than sent half-built. A path that renders to an
|
|
80
|
+
* absolute URL is used as it is; any other is joined to `baseUrl`.
|
|
81
|
+
*
|
|
82
|
+
* Pure and browser-safe: no database, config or mail imports.
|
|
83
|
+
*/
|
|
84
|
+
export {
|
|
85
|
+
accountNotificationTemplates,
|
|
86
|
+
type AccountNotificationPaths,
|
|
87
|
+
type AccountNotificationTemplateName,
|
|
88
|
+
} from './template-catalogs/account';
|
|
89
|
+
export {
|
|
90
|
+
commerceNotificationTemplates,
|
|
91
|
+
type CommerceNotificationPaths,
|
|
92
|
+
type CommerceNotificationTemplateName,
|
|
93
|
+
} from './template-catalogs/commerce';
|
|
94
|
+
export {
|
|
95
|
+
subscriptionNotificationTemplates,
|
|
96
|
+
type SubscriptionNotificationPaths,
|
|
97
|
+
type SubscriptionNotificationTemplateName,
|
|
98
|
+
} from './template-catalogs/subscription';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
function f(t={}){let e=t.resetPassword??"/forgot-password";return{password_changed:{copy:{en:{subject:"Your {{app}} password was changed",body:`The password for your {{app}} account was changed.
|
|
3
|
+
|
|
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.
|
|
5
|
+
|
|
6
|
+
Warst du das, musst du nichts weiter tun. Falls nicht, setze dein Passwort sofort zur\xFCck und gib uns unter {{support_email}} Bescheid.`}},action:{path:e,label:{en:"Reset your password",de:"Passwort zur\xFCcksetzen"}}},email_changed:{copy:{en:{subject:"Your {{app}} sign-in email was changed",body:`The email address for your {{app}} account was changed from {{old_email}} to {{new_email}}. From now on, you sign in and get email at {{new_email}}.
|
|
7
|
+
|
|
8
|
+
If you didn\u2019t make this change, write to us at {{support_email}} straight away.`},de:{subject:"Deine Anmelde-E-Mail bei {{app}} wurde ge\xE4ndert",body:`Die E-Mail-Adresse deines {{app}}-Kontos wurde von {{old_email}} zu {{new_email}} ge\xE4ndert. Ab jetzt meldest du dich mit {{new_email}} an und bekommst dort deine E-Mails.
|
|
9
|
+
|
|
10
|
+
Warst du das nicht, schreib uns sofort an {{support_email}}.`}}},account_deleted:{copy:{en:{subject:"Your {{app}} account is deleted",body:`Your {{app}} account was deleted on {{date}}. This is the last email we\u2019ll send to this address.
|
|
11
|
+
|
|
12
|
+
If you didn\u2019t ask for this, write to us at {{support_email}}.`},de:{subject:"Dein {{app}}-Konto ist gel\xF6scht",body:`Dein {{app}}-Konto wurde am {{date}} gel\xF6scht. Das ist die letzte E-Mail, die wir an diese Adresse schicken.
|
|
13
|
+
|
|
14
|
+
Hast du das nicht veranlasst, schreib uns an {{support_email}}.`}}},account_created:{copy:{en:{subject:"{{inviter}} added you to {{app}}",body:`{{inviter}} created an {{app}} account for you with this email address.
|
|
15
|
+
|
|
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.
|
|
17
|
+
|
|
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 m(t={}){let e=t.orders??"/orders",n=t.sales??"/orders";return{order_receipt:{copy:{en:{subject:"Your receipt from {{seller}}",body:`Thanks for your purchase from {{seller}}.
|
|
19
|
+
|
|
20
|
+
{{lines}}
|
|
21
|
+
Total: {{total}}
|
|
22
|
+
|
|
23
|
+
Order {{order_number}}, paid {{date}}.`},de:{subject:"Deine Quittung von {{seller}}",body:`Danke f\xFCr deinen Einkauf bei {{seller}}.
|
|
24
|
+
|
|
25
|
+
{{lines}}
|
|
26
|
+
Summe: {{total}}
|
|
27
|
+
|
|
28
|
+
Bestellung {{order_number}}, bezahlt am {{date}}.`}},action:{path:e,label:{en:"View your purchases",de:"Deine K\xE4ufe ansehen"}}},order_refunded:{copy:{en:{subject:"Refund from {{seller}}: {{amount}}",body:`{{seller}} refunded {{amount}} for order {{order_number}}.
|
|
29
|
+
|
|
30
|
+
Money paid by card goes back to that card, which usually takes 5 to 10 business days to show. Anything refunded as account credit is ready for your next purchase.`},de:{subject:"Erstattung von {{seller}}: {{amount}}",body:`{{seller}} hat dir {{amount}} f\xFCr die Bestellung {{order_number}} erstattet.
|
|
31
|
+
|
|
32
|
+
Per Karte Bezahltes geht auf diese Karte zur\xFCck, was meist 5 bis 10 Werktage dauert. Als Guthaben Erstattetes kannst du bei deinem n\xE4chsten Einkauf verwenden.`}},action:{path:e,label:{en:"View your purchases",de:"Deine K\xE4ufe ansehen"}}},new_sale:{copy:{en:{subject:"New sale: {{item}}",body:`{{buyer}} bought {{item}} for {{total}}.
|
|
33
|
+
|
|
34
|
+
Order {{order_number}}.`},de:{subject:"Neuer Verkauf: {{item}}",body:`{{buyer}} hat {{item}} f\xFCr {{total}} gekauft.
|
|
35
|
+
|
|
36
|
+
Bestellung {{order_number}}.`}},action:{path:n,label:{en:"View your sales",de:"Verk\xE4ufe ansehen"}}},account_credit_added:{copy:{en:{subject:"{{amount}} credit from {{seller}}",body:`{{seller}} added {{amount}} to your account: {{reason}}
|
|
37
|
+
|
|
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}}
|
|
39
|
+
|
|
40
|
+
Dein Guthaben betr\xE4gt jetzt {{balance}}. Du kannst es bei deinem n\xE4chsten Einkauf verwenden.`}}}}}function b(t={}){let e=t.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:e,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:e,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:e,label:{en:"Choose a plan",de:"Tarif w\xE4hlen"}}}}}var u=/\{\{\s*([a-z0-9_]+)\s*\}\}/gi;function k(t){return t}function c(t,e={}){return t.replace(u,(n,i)=>{let a=e[i];return a==null?"":String(a)})}function g(t){return[...new Set([...t.matchAll(u)].map((e)=>e[1]))]}function h(t,e,n="en"){let i=String(e||"").trim();if(i&&t.includes(i))return i;let a=i.split(/[-_]/)[0]?.toLowerCase();if(a&&t.includes(a))return a;if(t.includes(n))return n;let r=t[0];if(!r)throw Error("A notification template needs copy in at least one language");return r}function y(t,e){if(/^https?:\/\//i.test(e))return e;let n=String(t||"").replace(/\/+$/,"");if(!e)return n;return`${n}${e.startsWith("/")?"":"/"}${e}`}function T(t,e={},n={}){let i=n.fallbackLocale||"en",a=h(Object.keys(t.copy),n.locale,i),r=t.copy[a],s={subject:c(r.subject,e),body:c(r.body,e)},o=t.action;if(o){let p=g(o.path).some((d)=>e[d]==null||e[d]===""),l=o.label[a]??o.label[i]??Object.values(o.label)[0];if(!p&&l)s.action={label:l,url:y(n.baseUrl,c(o.path,e))}}return s}function j(t,e={}){let n=Object.keys(t),i=()=>typeof e.baseUrl==="function"?e.baseUrl():e.baseUrl;return{templates:t,names:n,has:(a)=>Object.hasOwn(t,a),render(a,r={},s){let o=t[a];if(!o)throw Error(`Unknown notification template "${String(a)}"`);return T(o,r,{locale:s,fallbackLocale:e.fallbackLocale,baseUrl:i()})}}}export{f as accountNotificationTemplates,m as commerceNotificationTemplates,j as createNotificationTemplates,k as defineNotificationTemplates,c as interpolateNotificationText,g as notificationTemplateVariables,T as renderNotificationTemplate,h as resolveNotificationLocale,b as subscriptionNotificationTemplates};
|
|
@@ -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.139",
|
|
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.139",
|
|
68
|
+
"@stacksjs/cli": "0.75.139",
|
|
69
|
+
"@stacksjs/config": "0.75.139",
|
|
70
|
+
"@stacksjs/database": "0.75.139",
|
|
71
|
+
"@stacksjs/email": "0.75.139",
|
|
72
|
+
"@stacksjs/push": "0.75.139",
|
|
73
|
+
"@stacksjs/realtime": "0.75.139",
|
|
74
|
+
"@stacksjs/sms": "0.75.139"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@stacksjs/error-handling": "0.75.
|
|
78
|
-
"@stacksjs/types": "0.75.
|
|
77
|
+
"@stacksjs/error-handling": "0.75.139",
|
|
78
|
+
"@stacksjs/types": "0.75.139",
|
|
79
79
|
"better-dx": "^0.2.24"
|
|
80
80
|
}
|
|
81
81
|
}
|