@spfn/notification 0.1.0-beta.22 → 0.1.0-beta.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +144 -82
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -206,9 +206,12 @@ var awsSesProvider = {
|
|
|
206
206
|
};
|
|
207
207
|
|
|
208
208
|
// src/tracking/token.ts
|
|
209
|
-
import { createHmac } from "crypto";
|
|
210
|
-
function
|
|
211
|
-
return
|
|
209
|
+
import { createHmac, createHash, timingSafeEqual } from "crypto";
|
|
210
|
+
function hashClickUrl(url) {
|
|
211
|
+
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
212
|
+
}
|
|
213
|
+
function toBase64Url(buffer2) {
|
|
214
|
+
return buffer2.toString("base64url");
|
|
212
215
|
}
|
|
213
216
|
function fromBase64Url(str) {
|
|
214
217
|
return Buffer.from(str, "base64url").toString("utf8");
|
|
@@ -237,7 +240,9 @@ function verify(token) {
|
|
|
237
240
|
const payload = fromBase64Url(payloadEncoded);
|
|
238
241
|
const expectedHmac = createHmac("sha256", secret).update(payload).digest();
|
|
239
242
|
const expectedHmacEncoded = toBase64Url(expectedHmac);
|
|
240
|
-
|
|
243
|
+
const provided = Buffer.from(hmacEncoded);
|
|
244
|
+
const expected = Buffer.from(expectedHmacEncoded);
|
|
245
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
241
246
|
return { valid: false };
|
|
242
247
|
}
|
|
243
248
|
return { valid: true, payload };
|
|
@@ -245,8 +250,8 @@ function verify(token) {
|
|
|
245
250
|
function generateOpenToken(notificationId) {
|
|
246
251
|
return sign(`o:${notificationId}`);
|
|
247
252
|
}
|
|
248
|
-
function generateClickToken(notificationId, linkIndex) {
|
|
249
|
-
return sign(`c:${notificationId}:${linkIndex}`);
|
|
253
|
+
function generateClickToken(notificationId, linkIndex, url) {
|
|
254
|
+
return sign(`c:${notificationId}:${linkIndex}:${hashClickUrl(url)}`);
|
|
250
255
|
}
|
|
251
256
|
function verifyOpenToken(token) {
|
|
252
257
|
const result = verify(token);
|
|
@@ -264,14 +269,15 @@ function verifyClickToken(token) {
|
|
|
264
269
|
if (!result.valid || !result.payload) {
|
|
265
270
|
return { valid: false };
|
|
266
271
|
}
|
|
267
|
-
const match = result.payload.match(/^c:(\d+):(\d+)
|
|
272
|
+
const match = result.payload.match(/^c:(\d+):(\d+)(?::([0-9a-f]{16}))?$/);
|
|
268
273
|
if (!match) {
|
|
269
274
|
return { valid: false };
|
|
270
275
|
}
|
|
271
276
|
return {
|
|
272
277
|
valid: true,
|
|
273
278
|
notificationId: Number(match[1]),
|
|
274
|
-
linkIndex: Number(match[2])
|
|
279
|
+
linkIndex: Number(match[2]),
|
|
280
|
+
urlHash: match[3]
|
|
275
281
|
};
|
|
276
282
|
}
|
|
277
283
|
|
|
@@ -296,7 +302,7 @@ function processTrackingHtml(html, options) {
|
|
|
296
302
|
return match;
|
|
297
303
|
}
|
|
298
304
|
const currentIndex = linkIndex++;
|
|
299
|
-
const clickToken = generateClickToken(notificationId, currentIndex);
|
|
305
|
+
const clickToken = generateClickToken(notificationId, currentIndex, url);
|
|
300
306
|
const trackingUrl = `${baseUrl}/_noti/t/c/${clickToken}?url=${encodeURIComponent(url)}`;
|
|
301
307
|
trackedLinks.push({ index: currentIndex, url });
|
|
302
308
|
return `<a ${before}href="${trackingUrl}"${after}>`;
|
|
@@ -381,10 +387,24 @@ function parseExpression(expr) {
|
|
|
381
387
|
arg: filterPart.slice(colonIndex + 1)
|
|
382
388
|
};
|
|
383
389
|
}
|
|
390
|
+
var BLOCKED_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
391
|
+
var HTML_ESCAPES = {
|
|
392
|
+
"&": "&",
|
|
393
|
+
"<": "<",
|
|
394
|
+
">": ">",
|
|
395
|
+
'"': """,
|
|
396
|
+
"'": "'"
|
|
397
|
+
};
|
|
398
|
+
function escapeHtml(value) {
|
|
399
|
+
return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
|
|
400
|
+
}
|
|
384
401
|
function getValue(data, path) {
|
|
385
402
|
const parts = path.split(".");
|
|
386
403
|
let value = data;
|
|
387
404
|
for (const part of parts) {
|
|
405
|
+
if (BLOCKED_PATH_SEGMENTS.has(part)) {
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
388
408
|
if (value === null || value === void 0) {
|
|
389
409
|
return void 0;
|
|
390
410
|
}
|
|
@@ -392,17 +412,19 @@ function getValue(data, path) {
|
|
|
392
412
|
}
|
|
393
413
|
return value;
|
|
394
414
|
}
|
|
395
|
-
function render(template, data) {
|
|
415
|
+
function render(template, data, options = {}) {
|
|
416
|
+
const escape = options.escape ?? false;
|
|
396
417
|
return template.replace(/\{\{([^}]+)\}\}/g, (match, expr) => {
|
|
397
418
|
const { variable, filter, arg } = parseExpression(expr.trim());
|
|
398
|
-
|
|
419
|
+
const value = getValue(data, variable);
|
|
399
420
|
if (value === void 0) {
|
|
400
421
|
return match;
|
|
401
422
|
}
|
|
402
|
-
if (filter
|
|
403
|
-
return
|
|
423
|
+
if (filter === "raw") {
|
|
424
|
+
return String(value);
|
|
404
425
|
}
|
|
405
|
-
|
|
426
|
+
const rendered = filter && filters[filter] ? filters[filter](value, arg) : String(value);
|
|
427
|
+
return escape ? escapeHtml(rendered) : rendered;
|
|
406
428
|
});
|
|
407
429
|
}
|
|
408
430
|
function registerFilter(name, fn) {
|
|
@@ -444,7 +466,8 @@ function renderTemplate(name, data, channel) {
|
|
|
444
466
|
function renderEmailTemplate(template, data) {
|
|
445
467
|
return {
|
|
446
468
|
subject: render(template.subject, data),
|
|
447
|
-
|
|
469
|
+
// Escape interpolated values on the HTML path (caller data → markup injection).
|
|
470
|
+
html: template.html ? render(template.html, data, { escape: true }) : void 0,
|
|
448
471
|
text: template.text ? render(template.text, data) : void 0
|
|
449
472
|
};
|
|
450
473
|
}
|
|
@@ -597,8 +620,8 @@ function registerBuiltinTemplates() {
|
|
|
597
620
|
}
|
|
598
621
|
|
|
599
622
|
// src/services/notification.service.ts
|
|
600
|
-
import { create, createMany, findOne, findMany, updateOne, count } from "@spfn/core/db";
|
|
601
|
-
import { desc, eq, and, gte, lte } from "drizzle-orm";
|
|
623
|
+
import { create, createMany, findOne, findMany, updateOne, count, getDatabase } from "@spfn/core/db";
|
|
624
|
+
import { desc, eq, and, gte, lte, count as drizzleCount } from "drizzle-orm";
|
|
602
625
|
|
|
603
626
|
// src/entities/schema.ts
|
|
604
627
|
import { createSchema } from "@spfn/core/db";
|
|
@@ -840,15 +863,33 @@ async function countNotifications(options = {}) {
|
|
|
840
863
|
return await count(notifications);
|
|
841
864
|
}
|
|
842
865
|
async function getNotificationStats(options = {}) {
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
866
|
+
const conditions = [];
|
|
867
|
+
if (options.channel) {
|
|
868
|
+
conditions.push(eq(notifications.channel, options.channel));
|
|
869
|
+
}
|
|
870
|
+
if (options.from) {
|
|
871
|
+
conditions.push(gte(notifications.createdAt, options.from));
|
|
872
|
+
}
|
|
873
|
+
if (options.to) {
|
|
874
|
+
conditions.push(lte(notifications.createdAt, options.to));
|
|
875
|
+
}
|
|
876
|
+
const rows = await getDatabase("read").select({ status: notifications.status, count: drizzleCount() }).from(notifications).where(conditions.length > 0 ? and(...conditions) : void 0).groupBy(notifications.status);
|
|
877
|
+
const stats = {
|
|
878
|
+
total: 0,
|
|
879
|
+
scheduled: 0,
|
|
880
|
+
pending: 0,
|
|
881
|
+
sent: 0,
|
|
882
|
+
failed: 0,
|
|
883
|
+
cancelled: 0
|
|
884
|
+
};
|
|
885
|
+
for (const row of rows) {
|
|
886
|
+
const n = Number(row.count);
|
|
887
|
+
stats.total += n;
|
|
888
|
+
if (row.status && row.status in stats) {
|
|
889
|
+
stats[row.status] = n;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return stats;
|
|
852
893
|
}
|
|
853
894
|
async function findScheduledNotifications(options = {}) {
|
|
854
895
|
const conditions = [eq(notifications.status, "scheduled")];
|
|
@@ -1786,11 +1827,11 @@ function IsTemplateLiteralFinite(schema) {
|
|
|
1786
1827
|
// ../../node_modules/.pnpm/@sinclair+typebox@0.34.41/node_modules/@sinclair/typebox/build/esm/type/template-literal/generate.mjs
|
|
1787
1828
|
var TemplateLiteralGenerateError = class extends TypeBoxError {
|
|
1788
1829
|
};
|
|
1789
|
-
function* GenerateReduce(
|
|
1790
|
-
if (
|
|
1791
|
-
return yield*
|
|
1792
|
-
for (const left of
|
|
1793
|
-
for (const right of GenerateReduce(
|
|
1830
|
+
function* GenerateReduce(buffer2) {
|
|
1831
|
+
if (buffer2.length === 1)
|
|
1832
|
+
return yield* buffer2[0];
|
|
1833
|
+
for (const left of buffer2[0]) {
|
|
1834
|
+
for (const right of GenerateReduce(buffer2.slice(1))) {
|
|
1794
1835
|
yield `${left}${right}`;
|
|
1795
1836
|
}
|
|
1796
1837
|
}
|
|
@@ -3623,15 +3664,8 @@ async function sendEmail(params) {
|
|
|
3623
3664
|
log2.error("Email send failed", { to: recipients, subject, error: result.error });
|
|
3624
3665
|
}
|
|
3625
3666
|
if (historyId && isHistoryEnabled()) {
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
await markNotificationSent(historyId, result.messageId);
|
|
3629
|
-
} else {
|
|
3630
|
-
await markNotificationFailed(historyId, result.error || "Unknown error");
|
|
3631
|
-
}
|
|
3632
|
-
} catch (error) {
|
|
3633
|
-
log2.warn("Failed to update notification history record", error);
|
|
3634
|
-
}
|
|
3667
|
+
const update = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
|
|
3668
|
+
update.catch((error) => log2.warn("Failed to update notification history record", error));
|
|
3635
3669
|
}
|
|
3636
3670
|
return result;
|
|
3637
3671
|
}
|
|
@@ -3928,8 +3962,7 @@ async function sendSMS(params) {
|
|
|
3928
3962
|
};
|
|
3929
3963
|
}
|
|
3930
3964
|
const provider = getProvider2();
|
|
3931
|
-
const
|
|
3932
|
-
for (const recipient of recipients) {
|
|
3965
|
+
const sendOne = async (recipient) => {
|
|
3933
3966
|
const normalizedPhone = normalizePhoneNumber(recipient);
|
|
3934
3967
|
const internalParams = {
|
|
3935
3968
|
to: normalizedPhone,
|
|
@@ -3958,18 +3991,12 @@ async function sendSMS(params) {
|
|
|
3958
3991
|
log4.error("SMS send failed", { to: normalizedPhone, error: result.error });
|
|
3959
3992
|
}
|
|
3960
3993
|
if (historyId && isHistoryEnabled()) {
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
await markNotificationSent(historyId, result.messageId);
|
|
3964
|
-
} else {
|
|
3965
|
-
await markNotificationFailed(historyId, result.error || "Unknown error");
|
|
3966
|
-
}
|
|
3967
|
-
} catch (error) {
|
|
3968
|
-
log4.warn("Failed to update notification history record", error);
|
|
3969
|
-
}
|
|
3994
|
+
const update = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
|
|
3995
|
+
update.catch((error) => log4.warn("Failed to update notification history record", error));
|
|
3970
3996
|
}
|
|
3971
|
-
|
|
3972
|
-
}
|
|
3997
|
+
return result;
|
|
3998
|
+
};
|
|
3999
|
+
const results = await runWithConcurrency(recipients, sendOne);
|
|
3973
4000
|
const allSuccess = results.every((r) => r.success);
|
|
3974
4001
|
const messageIds = results.filter((r) => r.messageId).map((r) => r.messageId).join(",");
|
|
3975
4002
|
const errors = results.filter((r) => r.error).map((r) => r.error).join("; ");
|
|
@@ -4248,15 +4275,8 @@ async function sendSlack(params) {
|
|
|
4248
4275
|
log6.error("Slack send failed", { error: result.error });
|
|
4249
4276
|
}
|
|
4250
4277
|
if (historyId && isHistoryEnabled()) {
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
await markNotificationSent(historyId, result.messageId);
|
|
4254
|
-
} else {
|
|
4255
|
-
await markNotificationFailed(historyId, result.error || "Unknown error");
|
|
4256
|
-
}
|
|
4257
|
-
} catch (error) {
|
|
4258
|
-
log6.warn("Failed to update notification history record", error);
|
|
4259
|
-
}
|
|
4278
|
+
const update = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
|
|
4279
|
+
update.catch((error) => log6.warn("Failed to update notification history record", error));
|
|
4260
4280
|
}
|
|
4261
4281
|
return result;
|
|
4262
4282
|
}
|
|
@@ -4768,37 +4788,66 @@ import { route } from "@spfn/core/route";
|
|
|
4768
4788
|
import { defineRouter } from "@spfn/core/route";
|
|
4769
4789
|
|
|
4770
4790
|
// src/tracking/tracking.service.ts
|
|
4771
|
-
import {
|
|
4772
|
-
import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as
|
|
4791
|
+
import { getDatabase as getDatabase2 } from "@spfn/core/db";
|
|
4792
|
+
import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as drizzleCount2, countDistinct } from "drizzle-orm";
|
|
4773
4793
|
import { logger as logger7 } from "@spfn/core/logger";
|
|
4774
4794
|
var log7 = logger7.child("@spfn/notification:tracking");
|
|
4795
|
+
var FLUSH_SIZE = 200;
|
|
4796
|
+
var FLUSH_INTERVAL_MS = 2e3;
|
|
4797
|
+
var MAX_BUFFER = 1e4;
|
|
4798
|
+
var buffer = [];
|
|
4799
|
+
var flushTimer = null;
|
|
4800
|
+
async function flushTrackingEvents() {
|
|
4801
|
+
if (flushTimer) {
|
|
4802
|
+
clearTimeout(flushTimer);
|
|
4803
|
+
flushTimer = null;
|
|
4804
|
+
}
|
|
4805
|
+
if (buffer.length === 0) {
|
|
4806
|
+
return;
|
|
4807
|
+
}
|
|
4808
|
+
const batch = buffer.splice(0, buffer.length);
|
|
4809
|
+
try {
|
|
4810
|
+
await getDatabase2("write").insert(trackingEvents).values(batch);
|
|
4811
|
+
} catch (error) {
|
|
4812
|
+
log7.warn(`Failed to flush ${batch.length} tracking events`, error);
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
function enqueueTrackingEvent(event) {
|
|
4816
|
+
if (buffer.length >= MAX_BUFFER) {
|
|
4817
|
+
log7.warn("Tracking buffer full \u2014 dropping event");
|
|
4818
|
+
return;
|
|
4819
|
+
}
|
|
4820
|
+
buffer.push(event);
|
|
4821
|
+
if (buffer.length >= FLUSH_SIZE) {
|
|
4822
|
+
void flushTrackingEvents();
|
|
4823
|
+
} else if (!flushTimer) {
|
|
4824
|
+
flushTimer = setTimeout(() => void flushTrackingEvents(), FLUSH_INTERVAL_MS);
|
|
4825
|
+
flushTimer.unref?.();
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4775
4828
|
function recordOpenEvent(notificationId, meta) {
|
|
4776
|
-
|
|
4829
|
+
enqueueTrackingEvent({
|
|
4777
4830
|
notificationId,
|
|
4778
4831
|
type: "open",
|
|
4779
4832
|
ipAddress: meta?.ipAddress,
|
|
4780
4833
|
userAgent: meta?.userAgent
|
|
4781
|
-
}).catch((error) => {
|
|
4782
|
-
log7.warn("Failed to record open event", error);
|
|
4783
4834
|
});
|
|
4784
4835
|
}
|
|
4785
4836
|
function recordClickEvent(notificationId, linkIndex, linkUrl, meta) {
|
|
4786
|
-
|
|
4837
|
+
enqueueTrackingEvent({
|
|
4787
4838
|
notificationId,
|
|
4788
4839
|
type: "click",
|
|
4789
4840
|
linkUrl,
|
|
4790
4841
|
linkIndex,
|
|
4791
4842
|
ipAddress: meta?.ipAddress,
|
|
4792
4843
|
userAgent: meta?.userAgent
|
|
4793
|
-
}).catch((error) => {
|
|
4794
|
-
log7.warn("Failed to record click event", error);
|
|
4795
4844
|
});
|
|
4796
4845
|
}
|
|
4797
4846
|
async function getTrackingStats(notificationId) {
|
|
4798
|
-
const db =
|
|
4847
|
+
const db = getDatabase2("read");
|
|
4799
4848
|
const rows = await db.select({
|
|
4800
4849
|
type: trackingEvents.type,
|
|
4801
|
-
total:
|
|
4850
|
+
total: drizzleCount2(),
|
|
4802
4851
|
unique: countDistinct(trackingEvents.ipAddress)
|
|
4803
4852
|
}).from(trackingEvents).where(eq2(trackingEvents.notificationId, notificationId)).groupBy(trackingEvents.type);
|
|
4804
4853
|
const openRow = rows.find((r) => r.type === "open");
|
|
@@ -4811,7 +4860,7 @@ async function getTrackingStats(notificationId) {
|
|
|
4811
4860
|
};
|
|
4812
4861
|
}
|
|
4813
4862
|
async function getEngagementStats(options = {}) {
|
|
4814
|
-
const db =
|
|
4863
|
+
const db = getDatabase2("read");
|
|
4815
4864
|
const sentConditions = [eq2(notifications.status, "sent")];
|
|
4816
4865
|
if (options.channel) {
|
|
4817
4866
|
sentConditions.push(eq2(notifications.channel, options.channel));
|
|
@@ -4822,7 +4871,7 @@ async function getEngagementStats(options = {}) {
|
|
|
4822
4871
|
if (options.to) {
|
|
4823
4872
|
sentConditions.push(lte2(notifications.createdAt, options.to));
|
|
4824
4873
|
}
|
|
4825
|
-
const [sentResult] = await db.select({ count:
|
|
4874
|
+
const [sentResult] = await db.select({ count: drizzleCount2() }).from(notifications).where(and2(...sentConditions));
|
|
4826
4875
|
const sent = Number(sentResult?.count ?? 0);
|
|
4827
4876
|
if (sent === 0) {
|
|
4828
4877
|
return { sent: 0, opened: 0, clicked: 0, openRate: 0, clickRate: 0 };
|
|
@@ -4857,11 +4906,11 @@ async function getEngagementStats(options = {}) {
|
|
|
4857
4906
|
};
|
|
4858
4907
|
}
|
|
4859
4908
|
async function getClickDetails(notificationId) {
|
|
4860
|
-
const db =
|
|
4909
|
+
const db = getDatabase2("read");
|
|
4861
4910
|
const rows = await db.select({
|
|
4862
4911
|
linkUrl: trackingEvents.linkUrl,
|
|
4863
4912
|
linkIndex: trackingEvents.linkIndex,
|
|
4864
|
-
totalClicks:
|
|
4913
|
+
totalClicks: drizzleCount2(),
|
|
4865
4914
|
uniqueClicks: countDistinct(trackingEvents.ipAddress)
|
|
4866
4915
|
}).from(trackingEvents).where(
|
|
4867
4916
|
and2(
|
|
@@ -4919,14 +4968,27 @@ var trackClick = route.get("/_noti/t/c/:token").input({
|
|
|
4919
4968
|
const { params, query } = await c.data();
|
|
4920
4969
|
const targetUrl = query.url;
|
|
4921
4970
|
const result = verifyClickToken(params.token);
|
|
4922
|
-
if (result.valid
|
|
4923
|
-
recordClickEvent(result.notificationId, result.linkIndex, targetUrl, {
|
|
4924
|
-
ipAddress: c.raw.req.header("x-forwarded-for") ?? c.raw.req.header("x-real-ip"),
|
|
4925
|
-
userAgent: c.raw.req.header("user-agent")
|
|
4926
|
-
});
|
|
4927
|
-
} else {
|
|
4971
|
+
if (!result.valid || result.notificationId == null || result.linkIndex == null) {
|
|
4928
4972
|
log8.warn("Invalid click tracking token");
|
|
4973
|
+
return new Response("Not found", { status: 404 });
|
|
4929
4974
|
}
|
|
4975
|
+
let parsed;
|
|
4976
|
+
try {
|
|
4977
|
+
parsed = new URL(targetUrl);
|
|
4978
|
+
} catch {
|
|
4979
|
+
return new Response("Not found", { status: 404 });
|
|
4980
|
+
}
|
|
4981
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
4982
|
+
return new Response("Not found", { status: 404 });
|
|
4983
|
+
}
|
|
4984
|
+
if (result.urlHash && hashClickUrl(targetUrl) !== result.urlHash) {
|
|
4985
|
+
log8.warn("Click URL does not match signed token");
|
|
4986
|
+
return new Response("Not found", { status: 404 });
|
|
4987
|
+
}
|
|
4988
|
+
recordClickEvent(result.notificationId, result.linkIndex, targetUrl, {
|
|
4989
|
+
ipAddress: c.raw.req.header("x-forwarded-for") ?? c.raw.req.header("x-real-ip"),
|
|
4990
|
+
userAgent: c.raw.req.header("user-agent")
|
|
4991
|
+
});
|
|
4930
4992
|
return new Response(null, {
|
|
4931
4993
|
status: 302,
|
|
4932
4994
|
headers: {
|