@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 CHANGED
@@ -206,9 +206,12 @@ var awsSesProvider = {
206
206
  };
207
207
 
208
208
  // src/tracking/token.ts
209
- import { createHmac } from "crypto";
210
- function toBase64Url(buffer) {
211
- return buffer.toString("base64url");
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
- if (hmacEncoded !== expectedHmacEncoded) {
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
+ "&": "&amp;",
393
+ "<": "&lt;",
394
+ ">": "&gt;",
395
+ '"': "&quot;",
396
+ "'": "&#39;"
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
- let value = getValue(data, variable);
419
+ const value = getValue(data, variable);
399
420
  if (value === void 0) {
400
421
  return match;
401
422
  }
402
- if (filter && filters[filter]) {
403
- return filters[filter](value, arg);
423
+ if (filter === "raw") {
424
+ return String(value);
404
425
  }
405
- return String(value);
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
- html: template.html ? render(template.html, data) : void 0,
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 [total, scheduled, pending, sent, failed, cancelled] = await Promise.all([
844
- countNotifications(options),
845
- countNotifications({ ...options, status: "scheduled" }),
846
- countNotifications({ ...options, status: "pending" }),
847
- countNotifications({ ...options, status: "sent" }),
848
- countNotifications({ ...options, status: "failed" }),
849
- countNotifications({ ...options, status: "cancelled" })
850
- ]);
851
- return { total, scheduled, pending, sent, failed, cancelled };
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(buffer) {
1790
- if (buffer.length === 1)
1791
- return yield* buffer[0];
1792
- for (const left of buffer[0]) {
1793
- for (const right of GenerateReduce(buffer.slice(1))) {
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
- try {
3627
- if (result.success) {
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 results = [];
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
- try {
3962
- if (result.success) {
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
- results.push(result);
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
- try {
4252
- if (result.success) {
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 { create as create2, getDatabase } from "@spfn/core/db";
4772
- import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as drizzleCount, countDistinct } from "drizzle-orm";
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
- create2(trackingEvents, {
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
- create2(trackingEvents, {
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 = getDatabase("read");
4847
+ const db = getDatabase2("read");
4799
4848
  const rows = await db.select({
4800
4849
  type: trackingEvents.type,
4801
- total: drizzleCount(),
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 = getDatabase("read");
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: drizzleCount() }).from(notifications).where(and2(...sentConditions));
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 = getDatabase("read");
4909
+ const db = getDatabase2("read");
4861
4910
  const rows = await db.select({
4862
4911
  linkUrl: trackingEvents.linkUrl,
4863
4912
  linkIndex: trackingEvents.linkIndex,
4864
- totalClicks: drizzleCount(),
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 && result.notificationId != null && result.linkIndex != null) {
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: {