@spfn/notification 0.1.0-beta.13 → 0.1.0-beta.15

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
@@ -43,6 +43,29 @@ var notificationEnvSchema = defineEnvSchema({
43
43
  examples: ["https://hooks.slack.com/services/xxx/xxx/xxx"]
44
44
  })
45
45
  },
46
+ // Tracking
47
+ SPFN_NOTIFICATION_TRACKING_ENABLED: {
48
+ ...envString({
49
+ description: "Enable email engagement tracking (open/click)",
50
+ default: "false",
51
+ required: false,
52
+ examples: ["true", "false"]
53
+ })
54
+ },
55
+ SPFN_NOTIFICATION_TRACKING_SECRET: {
56
+ ...envString({
57
+ description: "HMAC secret key for tracking token signing",
58
+ required: false,
59
+ sensitive: true
60
+ })
61
+ },
62
+ SPFN_NOTIFICATION_TRACKING_BASE_URL: {
63
+ ...envString({
64
+ description: "Base URL for tracking endpoints",
65
+ required: false,
66
+ examples: ["https://api.example.com"]
67
+ })
68
+ },
46
69
  // AWS (shared with other AWS services)
47
70
  AWS_REGION: {
48
71
  ...envString({
@@ -93,6 +116,18 @@ function getAppName() {
93
116
  function isHistoryEnabled() {
94
117
  return globalConfig.enableHistory ?? false;
95
118
  }
119
+ function isTrackingEnabled() {
120
+ if (globalConfig.tracking?.enabled != null) {
121
+ return globalConfig.tracking.enabled;
122
+ }
123
+ return env.SPFN_NOTIFICATION_TRACKING_ENABLED === "true";
124
+ }
125
+ function getTrackingSecret() {
126
+ return globalConfig.tracking?.secret ?? env.SPFN_NOTIFICATION_TRACKING_SECRET;
127
+ }
128
+ function getTrackingBaseUrl() {
129
+ return globalConfig.tracking?.baseUrl ?? env.SPFN_NOTIFICATION_TRACKING_BASE_URL;
130
+ }
96
131
 
97
132
  // src/channels/email/providers/aws-ses.ts
98
133
  import { logger } from "@spfn/core/logger";
@@ -168,6 +203,114 @@ var awsSesProvider = {
168
203
  }
169
204
  };
170
205
 
206
+ // src/tracking/token.ts
207
+ import { createHmac } from "crypto";
208
+ function toBase64Url(buffer) {
209
+ return buffer.toString("base64url");
210
+ }
211
+ function fromBase64Url(str) {
212
+ return Buffer.from(str, "base64url").toString("utf8");
213
+ }
214
+ function sign(payload) {
215
+ const secret = getTrackingSecret();
216
+ if (!secret) {
217
+ throw new Error("Tracking secret is not configured");
218
+ }
219
+ const payloadEncoded = toBase64Url(Buffer.from(payload, "utf8"));
220
+ const hmac = createHmac("sha256", secret).update(payload).digest();
221
+ const hmacEncoded = toBase64Url(hmac);
222
+ return `${payloadEncoded}.${hmacEncoded}`;
223
+ }
224
+ function verify(token) {
225
+ const secret = getTrackingSecret();
226
+ if (!secret) {
227
+ return { valid: false };
228
+ }
229
+ const dotIndex = token.indexOf(".");
230
+ if (dotIndex === -1) {
231
+ return { valid: false };
232
+ }
233
+ const payloadEncoded = token.substring(0, dotIndex);
234
+ const hmacEncoded = token.substring(dotIndex + 1);
235
+ const payload = fromBase64Url(payloadEncoded);
236
+ const expectedHmac = createHmac("sha256", secret).update(payload).digest();
237
+ const expectedHmacEncoded = toBase64Url(expectedHmac);
238
+ if (hmacEncoded !== expectedHmacEncoded) {
239
+ return { valid: false };
240
+ }
241
+ return { valid: true, payload };
242
+ }
243
+ function generateOpenToken(notificationId) {
244
+ return sign(`o:${notificationId}`);
245
+ }
246
+ function generateClickToken(notificationId, linkIndex) {
247
+ return sign(`c:${notificationId}:${linkIndex}`);
248
+ }
249
+ function verifyOpenToken(token) {
250
+ const result = verify(token);
251
+ if (!result.valid || !result.payload) {
252
+ return { valid: false };
253
+ }
254
+ const match = result.payload.match(/^o:(\d+)$/);
255
+ if (!match) {
256
+ return { valid: false };
257
+ }
258
+ return { valid: true, notificationId: Number(match[1]) };
259
+ }
260
+ function verifyClickToken(token) {
261
+ const result = verify(token);
262
+ if (!result.valid || !result.payload) {
263
+ return { valid: false };
264
+ }
265
+ const match = result.payload.match(/^c:(\d+):(\d+)$/);
266
+ if (!match) {
267
+ return { valid: false };
268
+ }
269
+ return {
270
+ valid: true,
271
+ notificationId: Number(match[1]),
272
+ linkIndex: Number(match[2])
273
+ };
274
+ }
275
+
276
+ // src/tracking/processor.ts
277
+ var SKIP_PROTOCOLS = ["mailto:", "tel:", "sms:", "javascript:"];
278
+ function shouldSkipUrl(url) {
279
+ const trimmed = url.trim();
280
+ if (trimmed === "" || trimmed === "#" || trimmed.startsWith("#")) {
281
+ return true;
282
+ }
283
+ const lower = trimmed.toLowerCase();
284
+ return SKIP_PROTOCOLS.some((proto) => lower.startsWith(proto));
285
+ }
286
+ function processTrackingHtml(html, options) {
287
+ const { notificationId, baseUrl } = options;
288
+ const trackedLinks = [];
289
+ let linkIndex = 0;
290
+ const processedHtml = html.replace(
291
+ /<a\s([^>]*?)href\s*=\s*["']([^"']+)["']([^>]*?)>/gi,
292
+ (match, before, url, after) => {
293
+ if (shouldSkipUrl(url)) {
294
+ return match;
295
+ }
296
+ const currentIndex = linkIndex++;
297
+ const clickToken = generateClickToken(notificationId, currentIndex);
298
+ const trackingUrl = `${baseUrl}/_noti/t/c/${clickToken}?url=${encodeURIComponent(url)}`;
299
+ trackedLinks.push({ index: currentIndex, url });
300
+ return `<a ${before}href="${trackingUrl}"${after}>`;
301
+ }
302
+ );
303
+ const openToken = generateOpenToken(notificationId);
304
+ const pixel = `<img src="${baseUrl}/_noti/t/o/${openToken}" width="1" height="1" style="display:none" alt="" />`;
305
+ let finalHtml;
306
+ if (processedHtml.includes("</body>")) {
307
+ finalHtml = processedHtml.replace("</body>", `${pixel}</body>`);
308
+ } else {
309
+ finalHtml = processedHtml + pixel;
310
+ }
311
+ return { html: finalHtml, trackedLinks };
312
+ }
313
+
171
314
  // src/templates/renderer.ts
172
315
  var filters = {
173
316
  /**
@@ -503,6 +646,47 @@ var notifications = notificationSchema.table(
503
646
  ]
504
647
  );
505
648
 
649
+ // src/entities/tracking-events.ts
650
+ import { integer, text as text2, index as index2 } from "drizzle-orm/pg-core";
651
+ import { id as id2, timestamps as timestamps2 } from "@spfn/core/db";
652
+ var TRACKING_EVENT_TYPES = ["open", "click"];
653
+ var trackingEvents = notificationSchema.table(
654
+ "tracking_events",
655
+ {
656
+ id: id2(),
657
+ /**
658
+ * Reference to history.id (notification that was tracked)
659
+ */
660
+ notificationId: integer("notification_id").notNull(),
661
+ /**
662
+ * Event type (open or click)
663
+ */
664
+ type: text2("type", { enum: TRACKING_EVENT_TYPES }).notNull(),
665
+ /**
666
+ * Original URL (click events only)
667
+ */
668
+ linkUrl: text2("link_url"),
669
+ /**
670
+ * Link position index in the email (click events only)
671
+ */
672
+ linkIndex: integer("link_index"),
673
+ /**
674
+ * IP address of the requester
675
+ */
676
+ ipAddress: text2("ip_address"),
677
+ /**
678
+ * User agent string of the requester
679
+ */
680
+ userAgent: text2("user_agent"),
681
+ ...timestamps2()
682
+ },
683
+ (table) => [
684
+ index2("te_notification_id_idx").on(table.notificationId),
685
+ index2("te_type_idx").on(table.type),
686
+ index2("te_created_at_idx").on(table.createdAt)
687
+ ]
688
+ );
689
+
506
690
  // src/services/notification.service.ts
507
691
  async function createNotificationRecord(data) {
508
692
  return await create(notifications, {
@@ -516,13 +700,13 @@ async function createScheduledNotification(data) {
516
700
  status: "scheduled"
517
701
  });
518
702
  }
519
- async function updateNotificationJobId(id2, jobId) {
520
- return await updateOne(notifications, { id: id2 }, { jobId });
703
+ async function updateNotificationJobId(id3, jobId) {
704
+ return await updateOne(notifications, { id: id3 }, { jobId });
521
705
  }
522
- async function markNotificationSent(id2, providerMessageId) {
706
+ async function markNotificationSent(id3, providerMessageId) {
523
707
  return await updateOne(
524
708
  notifications,
525
- { id: id2 },
709
+ { id: id3 },
526
710
  {
527
711
  status: "sent",
528
712
  sentAt: /* @__PURE__ */ new Date(),
@@ -530,27 +714,27 @@ async function markNotificationSent(id2, providerMessageId) {
530
714
  }
531
715
  );
532
716
  }
533
- async function markNotificationFailed(id2, errorMessage) {
717
+ async function markNotificationFailed(id3, errorMessage) {
534
718
  return await updateOne(
535
719
  notifications,
536
- { id: id2 },
720
+ { id: id3 },
537
721
  {
538
722
  status: "failed",
539
723
  errorMessage
540
724
  }
541
725
  );
542
726
  }
543
- async function markNotificationPending(id2) {
727
+ async function markNotificationPending(id3) {
544
728
  return await updateOne(
545
729
  notifications,
546
- { id: id2 },
730
+ { id: id3 },
547
731
  { status: "pending" }
548
732
  );
549
733
  }
550
- async function cancelScheduledNotification(id2) {
734
+ async function cancelScheduledNotification(id3) {
551
735
  return await updateOne(
552
736
  notifications,
553
- { id: id2 },
737
+ { id: id3 },
554
738
  { status: "cancelled" }
555
739
  );
556
740
  }
@@ -653,7 +837,7 @@ function getProvider() {
653
837
  async function sendEmail(params) {
654
838
  const recipients = Array.isArray(params.to) ? params.to : [params.to];
655
839
  let subject = params.subject;
656
- let text2 = params.text;
840
+ let text3 = params.text;
657
841
  let html = params.html;
658
842
  if (params.template) {
659
843
  if (!hasTemplate(params.template)) {
@@ -666,7 +850,7 @@ async function sendEmail(params) {
666
850
  const rendered = renderTemplate(params.template, params.data || {}, "email");
667
851
  if (rendered.email) {
668
852
  subject = rendered.email.subject;
669
- text2 = rendered.email.text;
853
+ text3 = rendered.email.text;
670
854
  html = rendered.email.html;
671
855
  }
672
856
  }
@@ -677,7 +861,7 @@ async function sendEmail(params) {
677
861
  error: "Email subject is required"
678
862
  };
679
863
  }
680
- if (!text2 && !html) {
864
+ if (!text3 && !html) {
681
865
  log2.warn("Email content (text or html) is required", { to: recipients, subject });
682
866
  return {
683
867
  success: false,
@@ -689,7 +873,7 @@ async function sendEmail(params) {
689
873
  from: params.from || getEmailFrom(),
690
874
  replyTo: params.replyTo || getEmailReplyTo(),
691
875
  subject,
692
- text: text2,
876
+ text: text3,
693
877
  html
694
878
  };
695
879
  const provider = getProvider();
@@ -702,7 +886,7 @@ async function sendEmail(params) {
702
886
  templateName: params.template,
703
887
  templateData: params.data,
704
888
  subject,
705
- content: text2,
889
+ content: text3,
706
890
  providerName: provider.name
707
891
  });
708
892
  historyId = record.id;
@@ -710,6 +894,19 @@ async function sendEmail(params) {
710
894
  log2.warn("Failed to create notification history record", error);
711
895
  }
712
896
  }
897
+ const shouldTrack = params.tracking ?? isTrackingEnabled();
898
+ const trackingBaseUrl = getTrackingBaseUrl();
899
+ if (shouldTrack && historyId && internalParams.html && trackingBaseUrl) {
900
+ try {
901
+ const { html: trackedHtml } = processTrackingHtml(internalParams.html, {
902
+ notificationId: historyId,
903
+ baseUrl: trackingBaseUrl
904
+ });
905
+ internalParams.html = trackedHtml;
906
+ } catch (error) {
907
+ log2.warn("Failed to apply tracking to email HTML", error);
908
+ }
909
+ }
713
910
  const result = await provider.send(internalParams);
714
911
  if (result.success) {
715
912
  log2.info("Email sent", { to: recipients, subject, messageId: result.messageId });
@@ -977,7 +1174,7 @@ async function sendSlack(params) {
977
1174
  error: "Slack webhook URL is required. Set SPFN_NOTIFICATION_SLACK_WEBHOOK_URL or pass webhookUrl."
978
1175
  };
979
1176
  }
980
- let text2 = params.text;
1177
+ let text3 = params.text;
981
1178
  let blocks = params.blocks;
982
1179
  if (params.template) {
983
1180
  if (!hasTemplate(params.template)) {
@@ -989,11 +1186,11 @@ async function sendSlack(params) {
989
1186
  }
990
1187
  const rendered = renderTemplate(params.template, params.data || {}, "slack");
991
1188
  if (rendered.slack) {
992
- text2 = rendered.slack.text;
1189
+ text3 = rendered.slack.text;
993
1190
  blocks = rendered.slack.blocks;
994
1191
  }
995
1192
  }
996
- if (!text2 && !blocks) {
1193
+ if (!text3 && !blocks) {
997
1194
  log6.warn("Slack message requires text or blocks");
998
1195
  return {
999
1196
  success: false,
@@ -1002,7 +1199,7 @@ async function sendSlack(params) {
1002
1199
  }
1003
1200
  const internalParams = {
1004
1201
  webhookUrl,
1005
- text: text2,
1202
+ text: text3,
1006
1203
  blocks
1007
1204
  };
1008
1205
  const provider = getProvider3();
@@ -1014,7 +1211,7 @@ async function sendSlack(params) {
1014
1211
  recipient: webhookUrl,
1015
1212
  templateName: params.template,
1016
1213
  templateData: params.data,
1017
- content: text2,
1214
+ content: text3,
1018
1215
  providerName: provider.name
1019
1216
  });
1020
1217
  historyId = record.id;
@@ -1733,8 +1930,8 @@ function Array2(items, options) {
1733
1930
  }
1734
1931
 
1735
1932
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.41/node_modules/@sinclair/typebox/build/esm/type/argument/argument.mjs
1736
- function Argument(index2) {
1737
- return CreateType({ [Kind]: "Argument", index: index2 });
1933
+ function Argument(index3) {
1934
+ return CreateType({ [Kind]: "Argument", index: index3 });
1738
1935
  }
1739
1936
 
1740
1937
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.41/node_modules/@sinclair/typebox/build/esm/type/async-iterator/async-iterator.mjs
@@ -1813,28 +2010,28 @@ var TemplateLiteralParserError = class extends TypeBoxError {
1813
2010
  function Unescape(pattern) {
1814
2011
  return pattern.replace(/\\\$/g, "$").replace(/\\\*/g, "*").replace(/\\\^/g, "^").replace(/\\\|/g, "|").replace(/\\\(/g, "(").replace(/\\\)/g, ")");
1815
2012
  }
1816
- function IsNonEscaped(pattern, index2, char) {
1817
- return pattern[index2] === char && pattern.charCodeAt(index2 - 1) !== 92;
2013
+ function IsNonEscaped(pattern, index3, char) {
2014
+ return pattern[index3] === char && pattern.charCodeAt(index3 - 1) !== 92;
1818
2015
  }
1819
- function IsOpenParen(pattern, index2) {
1820
- return IsNonEscaped(pattern, index2, "(");
2016
+ function IsOpenParen(pattern, index3) {
2017
+ return IsNonEscaped(pattern, index3, "(");
1821
2018
  }
1822
- function IsCloseParen(pattern, index2) {
1823
- return IsNonEscaped(pattern, index2, ")");
2019
+ function IsCloseParen(pattern, index3) {
2020
+ return IsNonEscaped(pattern, index3, ")");
1824
2021
  }
1825
- function IsSeparator(pattern, index2) {
1826
- return IsNonEscaped(pattern, index2, "|");
2022
+ function IsSeparator(pattern, index3) {
2023
+ return IsNonEscaped(pattern, index3, "|");
1827
2024
  }
1828
2025
  function IsGroup(pattern) {
1829
2026
  if (!(IsOpenParen(pattern, 0) && IsCloseParen(pattern, pattern.length - 1)))
1830
2027
  return false;
1831
2028
  let count2 = 0;
1832
- for (let index2 = 0; index2 < pattern.length; index2++) {
1833
- if (IsOpenParen(pattern, index2))
2029
+ for (let index3 = 0; index3 < pattern.length; index3++) {
2030
+ if (IsOpenParen(pattern, index3))
1834
2031
  count2 += 1;
1835
- if (IsCloseParen(pattern, index2))
2032
+ if (IsCloseParen(pattern, index3))
1836
2033
  count2 -= 1;
1837
- if (count2 === 0 && index2 !== pattern.length - 1)
2034
+ if (count2 === 0 && index3 !== pattern.length - 1)
1838
2035
  return false;
1839
2036
  }
1840
2037
  return true;
@@ -1844,19 +2041,19 @@ function InGroup(pattern) {
1844
2041
  }
1845
2042
  function IsPrecedenceOr(pattern) {
1846
2043
  let count2 = 0;
1847
- for (let index2 = 0; index2 < pattern.length; index2++) {
1848
- if (IsOpenParen(pattern, index2))
2044
+ for (let index3 = 0; index3 < pattern.length; index3++) {
2045
+ if (IsOpenParen(pattern, index3))
1849
2046
  count2 += 1;
1850
- if (IsCloseParen(pattern, index2))
2047
+ if (IsCloseParen(pattern, index3))
1851
2048
  count2 -= 1;
1852
- if (IsSeparator(pattern, index2) && count2 === 0)
2049
+ if (IsSeparator(pattern, index3) && count2 === 0)
1853
2050
  return true;
1854
2051
  }
1855
2052
  return false;
1856
2053
  }
1857
2054
  function IsPrecedenceAnd(pattern) {
1858
- for (let index2 = 0; index2 < pattern.length; index2++) {
1859
- if (IsOpenParen(pattern, index2))
2055
+ for (let index3 = 0; index3 < pattern.length; index3++) {
2056
+ if (IsOpenParen(pattern, index3))
1860
2057
  return true;
1861
2058
  }
1862
2059
  return false;
@@ -1864,16 +2061,16 @@ function IsPrecedenceAnd(pattern) {
1864
2061
  function Or(pattern) {
1865
2062
  let [count2, start] = [0, 0];
1866
2063
  const expressions = [];
1867
- for (let index2 = 0; index2 < pattern.length; index2++) {
1868
- if (IsOpenParen(pattern, index2))
2064
+ for (let index3 = 0; index3 < pattern.length; index3++) {
2065
+ if (IsOpenParen(pattern, index3))
1869
2066
  count2 += 1;
1870
- if (IsCloseParen(pattern, index2))
2067
+ if (IsCloseParen(pattern, index3))
1871
2068
  count2 -= 1;
1872
- if (IsSeparator(pattern, index2) && count2 === 0) {
1873
- const range2 = pattern.slice(start, index2);
2069
+ if (IsSeparator(pattern, index3) && count2 === 0) {
2070
+ const range2 = pattern.slice(start, index3);
1874
2071
  if (range2.length > 0)
1875
2072
  expressions.push(TemplateLiteralParse(range2));
1876
- start = index2 + 1;
2073
+ start = index3 + 1;
1877
2074
  }
1878
2075
  }
1879
2076
  const range = pattern.slice(start);
@@ -1886,40 +2083,40 @@ function Or(pattern) {
1886
2083
  return { type: "or", expr: expressions };
1887
2084
  }
1888
2085
  function And(pattern) {
1889
- function Group(value, index2) {
1890
- if (!IsOpenParen(value, index2))
2086
+ function Group(value, index3) {
2087
+ if (!IsOpenParen(value, index3))
1891
2088
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Index must point to open parens`);
1892
2089
  let count2 = 0;
1893
- for (let scan = index2; scan < value.length; scan++) {
2090
+ for (let scan = index3; scan < value.length; scan++) {
1894
2091
  if (IsOpenParen(value, scan))
1895
2092
  count2 += 1;
1896
2093
  if (IsCloseParen(value, scan))
1897
2094
  count2 -= 1;
1898
2095
  if (count2 === 0)
1899
- return [index2, scan];
2096
+ return [index3, scan];
1900
2097
  }
1901
2098
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Unclosed group parens in expression`);
1902
2099
  }
1903
- function Range(pattern2, index2) {
1904
- for (let scan = index2; scan < pattern2.length; scan++) {
2100
+ function Range(pattern2, index3) {
2101
+ for (let scan = index3; scan < pattern2.length; scan++) {
1905
2102
  if (IsOpenParen(pattern2, scan))
1906
- return [index2, scan];
2103
+ return [index3, scan];
1907
2104
  }
1908
- return [index2, pattern2.length];
2105
+ return [index3, pattern2.length];
1909
2106
  }
1910
2107
  const expressions = [];
1911
- for (let index2 = 0; index2 < pattern.length; index2++) {
1912
- if (IsOpenParen(pattern, index2)) {
1913
- const [start, end] = Group(pattern, index2);
2108
+ for (let index3 = 0; index3 < pattern.length; index3++) {
2109
+ if (IsOpenParen(pattern, index3)) {
2110
+ const [start, end] = Group(pattern, index3);
1914
2111
  const range = pattern.slice(start, end + 1);
1915
2112
  expressions.push(TemplateLiteralParse(range));
1916
- index2 = end;
2113
+ index3 = end;
1917
2114
  } else {
1918
- const [start, end] = Range(pattern, index2);
2115
+ const [start, end] = Range(pattern, index3);
1919
2116
  const range = pattern.slice(start, end);
1920
2117
  if (range.length > 0)
1921
2118
  expressions.push(TemplateLiteralParse(range));
1922
- index2 = end - 1;
2119
+ index3 = end - 1;
1923
2120
  }
1924
2121
  }
1925
2122
  return expressions.length === 0 ? { type: "const", const: "" } : expressions.length === 1 ? expressions[0] : { type: "and", expr: expressions };
@@ -2611,13 +2808,13 @@ function FromBoolean(left, right) {
2611
2808
  return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : type_exports.IsRecord(right) ? FromRecordRight(left, right) : type_exports.IsBoolean(right) ? ExtendsResult.True : ExtendsResult.False;
2612
2809
  }
2613
2810
  function FromConstructor(left, right) {
2614
- return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsConstructor(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index2) => IntoBooleanResult(Visit3(right.parameters[index2], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2811
+ return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsConstructor(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index3) => IntoBooleanResult(Visit3(right.parameters[index3], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2615
2812
  }
2616
2813
  function FromDate(left, right) {
2617
2814
  return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : type_exports.IsRecord(right) ? FromRecordRight(left, right) : type_exports.IsDate(right) ? ExtendsResult.True : ExtendsResult.False;
2618
2815
  }
2619
2816
  function FromFunction(left, right) {
2620
- return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsFunction(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index2) => IntoBooleanResult(Visit3(right.parameters[index2], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2817
+ return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsFunction(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index3) => IntoBooleanResult(Visit3(right.parameters[index3], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2621
2818
  }
2622
2819
  function FromIntegerRight(left, right) {
2623
2820
  return type_exports.IsLiteral(left) && value_exports.IsNumber(left.const) ? ExtendsResult.True : type_exports.IsNumber(left) || type_exports.IsInteger(left) ? ExtendsResult.True : ExtendsResult.False;
@@ -2777,7 +2974,7 @@ function FromTupleRight(left, right) {
2777
2974
  return type_exports.IsNever(left) ? ExtendsResult.True : type_exports.IsUnknown(left) ? ExtendsResult.False : type_exports.IsAny(left) ? ExtendsResult.Union : ExtendsResult.False;
2778
2975
  }
2779
2976
  function FromTuple3(left, right) {
2780
- return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) && IsObjectArrayLike(right) ? ExtendsResult.True : type_exports.IsArray(right) && IsArrayOfTuple(left, right) ? ExtendsResult.True : !type_exports.IsTuple(right) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) || !value_exports.IsUndefined(left.items) && value_exports.IsUndefined(right.items) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) ? ExtendsResult.True : left.items.every((schema, index2) => Visit3(schema, right.items[index2]) === ExtendsResult.True) ? ExtendsResult.True : ExtendsResult.False;
2977
+ return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) && IsObjectArrayLike(right) ? ExtendsResult.True : type_exports.IsArray(right) && IsArrayOfTuple(left, right) ? ExtendsResult.True : !type_exports.IsTuple(right) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) || !value_exports.IsUndefined(left.items) && value_exports.IsUndefined(right.items) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) ? ExtendsResult.True : left.items.every((schema, index3) => Visit3(schema, right.items[index3]) === ExtendsResult.True) ? ExtendsResult.True : ExtendsResult.False;
2781
2978
  }
2782
2979
  function FromUint8Array(left, right) {
2783
2980
  return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : type_exports.IsRecord(right) ? FromRecordRight(left, right) : type_exports.IsUint8Array(right) ? ExtendsResult.True : ExtendsResult.False;
@@ -3726,7 +3923,7 @@ var sendScheduledSmsJob = job2("notification.send-scheduled-sms").input(SendSche
3726
3923
  async function scheduleEmail(params, options) {
3727
3924
  const recipients = Array.isArray(params.to) ? params.to : [params.to];
3728
3925
  let subject = params.subject;
3729
- let text2 = params.text;
3926
+ let text3 = params.text;
3730
3927
  let html = params.html;
3731
3928
  if (params.template) {
3732
3929
  if (!hasTemplate(params.template)) {
@@ -3738,7 +3935,7 @@ async function scheduleEmail(params, options) {
3738
3935
  const rendered = renderTemplate(params.template, params.data || {}, "email");
3739
3936
  if (rendered.email) {
3740
3937
  subject = rendered.email.subject;
3741
- text2 = rendered.email.text;
3938
+ text3 = rendered.email.text;
3742
3939
  html = rendered.email.html;
3743
3940
  }
3744
3941
  }
@@ -3748,7 +3945,7 @@ async function scheduleEmail(params, options) {
3748
3945
  error: "Email subject is required"
3749
3946
  };
3750
3947
  }
3751
- if (!text2 && !html) {
3948
+ if (!text3 && !html) {
3752
3949
  return {
3753
3950
  success: false,
3754
3951
  error: "Email content (text or html) is required"
@@ -3761,7 +3958,7 @@ async function scheduleEmail(params, options) {
3761
3958
  templateName: params.template,
3762
3959
  templateData: params.data,
3763
3960
  subject,
3764
- content: text2,
3961
+ content: text3,
3765
3962
  providerName: "pending",
3766
3963
  // Will be set when job runs
3767
3964
  scheduledAt: options.scheduledAt,
@@ -3896,12 +4093,12 @@ async function cancelNotification(notificationId) {
3896
4093
  }
3897
4094
  async function cancelNotificationsByReference(referenceType, referenceId) {
3898
4095
  const { findMany: findMany2 } = await import("@spfn/core/db");
3899
- const { eq: eq2, and: and2 } = await import("drizzle-orm");
4096
+ const { eq: eq3, and: and3 } = await import("drizzle-orm");
3900
4097
  const scheduledNotifications = await findMany2(notifications, {
3901
- where: and2(
3902
- eq2(notifications.referenceType, referenceType),
3903
- eq2(notifications.referenceId, referenceId),
3904
- eq2(notifications.status, "scheduled")
4098
+ where: and3(
4099
+ eq3(notifications.referenceType, referenceType),
4100
+ eq3(notifications.referenceId, referenceId),
4101
+ eq3(notifications.status, "scheduled")
3905
4102
  )
3906
4103
  });
3907
4104
  let cancelled = 0;
@@ -4045,11 +4242,189 @@ function createErrorSlackNotifier(options = {}) {
4045
4242
  };
4046
4243
  }
4047
4244
 
4245
+ // src/tracking/routes.ts
4246
+ import { route } from "@spfn/core/route";
4247
+ import { defineRouter } from "@spfn/core/route";
4248
+
4249
+ // src/tracking/tracking.service.ts
4250
+ import { create as create2, getDatabase } from "@spfn/core/db";
4251
+ import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as drizzleCount, countDistinct } from "drizzle-orm";
4252
+ import { logger as logger7 } from "@spfn/core/logger";
4253
+ var log7 = logger7.child("@spfn/notification:tracking");
4254
+ function recordOpenEvent(notificationId, meta) {
4255
+ create2(trackingEvents, {
4256
+ notificationId,
4257
+ type: "open",
4258
+ ipAddress: meta?.ipAddress,
4259
+ userAgent: meta?.userAgent
4260
+ }).catch((error) => {
4261
+ log7.warn("Failed to record open event", error);
4262
+ });
4263
+ }
4264
+ function recordClickEvent(notificationId, linkIndex, linkUrl, meta) {
4265
+ create2(trackingEvents, {
4266
+ notificationId,
4267
+ type: "click",
4268
+ linkUrl,
4269
+ linkIndex,
4270
+ ipAddress: meta?.ipAddress,
4271
+ userAgent: meta?.userAgent
4272
+ }).catch((error) => {
4273
+ log7.warn("Failed to record click event", error);
4274
+ });
4275
+ }
4276
+ async function getTrackingStats(notificationId) {
4277
+ const db = getDatabase("read");
4278
+ const rows = await db.select({
4279
+ type: trackingEvents.type,
4280
+ total: drizzleCount(),
4281
+ unique: countDistinct(trackingEvents.ipAddress)
4282
+ }).from(trackingEvents).where(eq2(trackingEvents.notificationId, notificationId)).groupBy(trackingEvents.type);
4283
+ const openRow = rows.find((r) => r.type === "open");
4284
+ const clickRow = rows.find((r) => r.type === "click");
4285
+ return {
4286
+ totalOpens: Number(openRow?.total ?? 0),
4287
+ uniqueOpens: Number(openRow?.unique ?? 0),
4288
+ totalClicks: Number(clickRow?.total ?? 0),
4289
+ uniqueClicks: Number(clickRow?.unique ?? 0)
4290
+ };
4291
+ }
4292
+ async function getEngagementStats(options = {}) {
4293
+ const db = getDatabase("read");
4294
+ const sentConditions = [eq2(notifications.status, "sent")];
4295
+ if (options.channel) {
4296
+ sentConditions.push(eq2(notifications.channel, options.channel));
4297
+ }
4298
+ if (options.from) {
4299
+ sentConditions.push(gte2(notifications.createdAt, options.from));
4300
+ }
4301
+ if (options.to) {
4302
+ sentConditions.push(lte2(notifications.createdAt, options.to));
4303
+ }
4304
+ const [sentResult] = await db.select({ count: drizzleCount() }).from(notifications).where(and2(...sentConditions));
4305
+ const sent = Number(sentResult?.count ?? 0);
4306
+ if (sent === 0) {
4307
+ return { sent: 0, opened: 0, clicked: 0, openRate: 0, clickRate: 0 };
4308
+ }
4309
+ const eventConditions = [];
4310
+ if (options.from) {
4311
+ eventConditions.push(gte2(trackingEvents.createdAt, options.from));
4312
+ }
4313
+ if (options.to) {
4314
+ eventConditions.push(lte2(trackingEvents.createdAt, options.to));
4315
+ }
4316
+ const openConditions = [
4317
+ eq2(trackingEvents.type, "open"),
4318
+ ...eventConditions
4319
+ ];
4320
+ const clickConditions = [
4321
+ eq2(trackingEvents.type, "click"),
4322
+ ...eventConditions
4323
+ ];
4324
+ const [[openResult], [clickResult]] = await Promise.all([
4325
+ db.select({ count: countDistinct(trackingEvents.notificationId) }).from(trackingEvents).where(and2(...openConditions)),
4326
+ db.select({ count: countDistinct(trackingEvents.notificationId) }).from(trackingEvents).where(and2(...clickConditions))
4327
+ ]);
4328
+ const opened = Number(openResult?.count ?? 0);
4329
+ const clicked = Number(clickResult?.count ?? 0);
4330
+ return {
4331
+ sent,
4332
+ opened,
4333
+ clicked,
4334
+ openRate: sent > 0 ? Number((opened / sent * 100).toFixed(2)) : 0,
4335
+ clickRate: sent > 0 ? Number((clicked / sent * 100).toFixed(2)) : 0
4336
+ };
4337
+ }
4338
+ async function getClickDetails(notificationId) {
4339
+ const db = getDatabase("read");
4340
+ const rows = await db.select({
4341
+ linkUrl: trackingEvents.linkUrl,
4342
+ linkIndex: trackingEvents.linkIndex,
4343
+ totalClicks: drizzleCount(),
4344
+ uniqueClicks: countDistinct(trackingEvents.ipAddress)
4345
+ }).from(trackingEvents).where(
4346
+ and2(
4347
+ eq2(trackingEvents.notificationId, notificationId),
4348
+ eq2(trackingEvents.type, "click")
4349
+ )
4350
+ ).groupBy(trackingEvents.linkUrl, trackingEvents.linkIndex).orderBy(trackingEvents.linkIndex);
4351
+ return rows.map((row) => ({
4352
+ linkUrl: row.linkUrl ?? "",
4353
+ linkIndex: row.linkIndex ?? 0,
4354
+ totalClicks: Number(row.totalClicks),
4355
+ uniqueClicks: Number(row.uniqueClicks)
4356
+ }));
4357
+ }
4358
+
4359
+ // src/tracking/routes.ts
4360
+ import { logger as logger8 } from "@spfn/core/logger";
4361
+ var log8 = logger8.child("@spfn/notification:tracking:routes");
4362
+ var TRANSPARENT_GIF = Buffer.from(
4363
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
4364
+ "base64"
4365
+ );
4366
+ var trackOpen = route.get("/_noti/t/o/:token").input({
4367
+ params: Type.Object({
4368
+ token: Type.String()
4369
+ })
4370
+ }).skip(["auth"]).handler(async (c) => {
4371
+ const { params } = await c.data();
4372
+ const result = verifyOpenToken(params.token);
4373
+ if (result.valid && result.notificationId) {
4374
+ recordOpenEvent(result.notificationId, {
4375
+ ipAddress: c.raw.req.header("x-forwarded-for") ?? c.raw.req.header("x-real-ip"),
4376
+ userAgent: c.raw.req.header("user-agent")
4377
+ });
4378
+ } else {
4379
+ log8.warn("Invalid open tracking token");
4380
+ }
4381
+ return new Response(TRANSPARENT_GIF, {
4382
+ status: 200,
4383
+ headers: {
4384
+ "Content-Type": "image/gif",
4385
+ "Content-Length": String(TRANSPARENT_GIF.length),
4386
+ "Cache-Control": "no-store, no-cache, must-revalidate"
4387
+ }
4388
+ });
4389
+ });
4390
+ var trackClick = route.get("/_noti/t/c/:token").input({
4391
+ params: Type.Object({
4392
+ token: Type.String()
4393
+ }),
4394
+ query: Type.Object({
4395
+ url: Type.String()
4396
+ })
4397
+ }).skip(["auth"]).handler(async (c) => {
4398
+ const { params, query } = await c.data();
4399
+ const targetUrl = query.url;
4400
+ const result = verifyClickToken(params.token);
4401
+ if (result.valid && result.notificationId != null && result.linkIndex != null) {
4402
+ recordClickEvent(result.notificationId, result.linkIndex, targetUrl, {
4403
+ ipAddress: c.raw.req.header("x-forwarded-for") ?? c.raw.req.header("x-real-ip"),
4404
+ userAgent: c.raw.req.header("user-agent")
4405
+ });
4406
+ } else {
4407
+ log8.warn("Invalid click tracking token");
4408
+ }
4409
+ return new Response(null, {
4410
+ status: 302,
4411
+ headers: {
4412
+ "Location": targetUrl,
4413
+ "Cache-Control": "no-store, no-cache, must-revalidate"
4414
+ }
4415
+ });
4416
+ });
4417
+ var trackingRouter = defineRouter({
4418
+ trackOpen,
4419
+ trackClick
4420
+ });
4421
+
4048
4422
  // src/server.ts
4049
4423
  registerBuiltinTemplates();
4050
4424
  export {
4051
4425
  NOTIFICATION_CHANNELS,
4052
4426
  NOTIFICATION_STATUSES,
4427
+ TRACKING_EVENT_TYPES,
4053
4428
  cancelNotification,
4054
4429
  cancelNotificationsByReference,
4055
4430
  cancelScheduledNotification,
@@ -4062,19 +4437,26 @@ export {
4062
4437
  findNotifications,
4063
4438
  findScheduledNotifications,
4064
4439
  getAppName,
4440
+ getClickDetails,
4065
4441
  getEmailFrom,
4066
4442
  getEmailReplyTo,
4443
+ getEngagementStats,
4067
4444
  getNotificationConfig,
4068
4445
  getNotificationStats,
4069
4446
  getSmsDefaultCountryCode,
4070
4447
  getTemplate,
4071
4448
  getTemplateNames,
4449
+ getTrackingBaseUrl,
4450
+ getTrackingSecret,
4451
+ getTrackingStats,
4072
4452
  hasTemplate,
4453
+ isTrackingEnabled,
4073
4454
  markNotificationFailed,
4074
4455
  markNotificationPending,
4075
4456
  markNotificationSent,
4076
4457
  notificationJobRouter,
4077
4458
  notifications,
4459
+ processTrackingHtml,
4078
4460
  registerBuiltinTemplates,
4079
4461
  registerEmailProvider,
4080
4462
  registerFilter,
@@ -4092,6 +4474,8 @@ export {
4092
4474
  sendScheduledSmsJob,
4093
4475
  sendSlack,
4094
4476
  sendSlackBulk,
4477
+ trackingEvents,
4478
+ trackingRouter,
4095
4479
  updateNotificationJobId
4096
4480
  };
4097
4481
  //# sourceMappingURL=server.js.map