@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/README.md CHANGED
@@ -9,6 +9,7 @@ Multi-channel notification system for SPFN applications.
9
9
  - **Template system**: Variable substitution with filters
10
10
  - **Scheduled delivery**: Schedule notifications for later via pg-boss
11
11
  - **History tracking**: Optional notification history with database storage
12
+ - **Email tracking**: Open pixel & click redirect tracking with engagement analytics
12
13
 
13
14
  ## Installation
14
15
 
@@ -70,6 +71,11 @@ SPFN_NOTIFICATION_EMAIL_FROM=noreply@example.com
70
71
  # SMS
71
72
  SPFN_NOTIFICATION_SMS_PROVIDER=aws-sns
72
73
 
74
+ # Tracking
75
+ SPFN_NOTIFICATION_TRACKING_ENABLED=true
76
+ SPFN_NOTIFICATION_TRACKING_SECRET=your-hmac-secret-key
77
+ SPFN_NOTIFICATION_TRACKING_BASE_URL=https://api.example.com
78
+
73
79
  # AWS Credentials
74
80
  AWS_REGION=ap-northeast-2
75
81
  AWS_ACCESS_KEY_ID=xxx
@@ -95,6 +101,11 @@ configureNotification({
95
101
  appName: 'MyApp',
96
102
  },
97
103
  enableHistory: true, // Enable notification history tracking
104
+ tracking: {
105
+ enabled: true, // Enable email tracking
106
+ secret: 'your-hmac-secret-key', // HMAC signing key
107
+ baseUrl: 'https://api.example.com', // Tracking endpoint URL
108
+ },
98
109
  });
99
110
  ```
100
111
 
@@ -348,6 +359,114 @@ const scheduled = await findScheduledNotifications({
348
359
  });
349
360
  ```
350
361
 
362
+ ## Email Tracking
363
+
364
+ Track email opens and link clicks to measure engagement.
365
+
366
+ ### How It Works
367
+
368
+ 1. **Open tracking**: A 1x1 transparent GIF is inserted before `</body>`. When the email client loads the image, an open event is recorded.
369
+ 2. **Click tracking**: `<a href>` links are wrapped with redirect URLs. When clicked, a click event is recorded and the user is redirected to the original URL.
370
+ 3. Links with `mailto:`, `tel:`, `sms:`, `javascript:`, and `#` protocols are automatically skipped.
371
+
372
+ ### Setup
373
+
374
+ Tracking requires:
375
+ - `enableHistory: true` (tracking events reference the history table via FK)
376
+ - `tracking.secret` configured (HMAC token signing)
377
+ - `tracking.baseUrl` configured (where tracking endpoints are accessible)
378
+ - `trackingRouter` registered in your app router
379
+
380
+ ```typescript
381
+ import { configureNotification, trackingRouter } from '@spfn/notification/server';
382
+ import { defineRouter } from '@spfn/core/route';
383
+
384
+ configureNotification({
385
+ enableHistory: true,
386
+ tracking: {
387
+ enabled: true,
388
+ secret: 'your-hmac-secret-key',
389
+ baseUrl: 'https://api.example.com',
390
+ },
391
+ });
392
+
393
+ // Register tracking router (provides /_noti/t/o/:token and /_noti/t/c/:token)
394
+ const appRouter = defineRouter({ ... })
395
+ .packages([trackingRouter]);
396
+ ```
397
+
398
+ ### Per-Email Control
399
+
400
+ Override the global tracking setting for individual emails:
401
+
402
+ ```typescript
403
+ // Force tracking on for this email (even if globally disabled)
404
+ await sendEmail({
405
+ to: 'user@example.com',
406
+ template: 'campaign',
407
+ data: { ... },
408
+ tracking: true,
409
+ });
410
+
411
+ // Force tracking off for this email (even if globally enabled)
412
+ await sendEmail({
413
+ to: 'admin@example.com',
414
+ subject: 'Internal Report',
415
+ html: '<p>...</p>',
416
+ tracking: false,
417
+ });
418
+ ```
419
+
420
+ ### Priority
421
+
422
+ ```
423
+ sendEmail({ tracking: true/false }) ← 1st: per-call override
424
+ configureNotification({ tracking: { enabled } }) ← 2nd: code config
425
+ SPFN_NOTIFICATION_TRACKING_ENABLED=true ← 3rd: environment variable
426
+ ```
427
+
428
+ ### Tracking Endpoints
429
+
430
+ These endpoints skip authentication (accessed by email clients):
431
+
432
+ | Endpoint | Response | Action |
433
+ |----------|----------|--------|
434
+ | `GET /_noti/t/o/:token` | 200 + 1x1 GIF | Records open event |
435
+ | `GET /_noti/t/c/:token?url=...` | 302 redirect | Records click event, redirects to original URL |
436
+
437
+ - Invalid tokens still return pixel/redirect (UX protection)
438
+ - `Cache-Control: no-store` for re-open tracking
439
+ - DB writes are fire-and-forget (response speed first)
440
+
441
+ ### Analytics
442
+
443
+ ```typescript
444
+ import {
445
+ getTrackingStats,
446
+ getEngagementStats,
447
+ getClickDetails,
448
+ } from '@spfn/notification/server';
449
+
450
+ // Stats for a specific notification
451
+ const stats = await getTrackingStats(notificationId);
452
+ // { totalOpens: 15, uniqueOpens: 8, totalClicks: 5, uniqueClicks: 3 }
453
+
454
+ // Overall engagement stats
455
+ const engagement = await getEngagementStats({ channel: 'email' });
456
+ // { sent: 1000, opened: 450, clicked: 120, openRate: 45.00, clickRate: 12.00 }
457
+
458
+ // Click details per link
459
+ const clicks = await getClickDetails(notificationId);
460
+ // [{ linkUrl: 'https://...', linkIndex: 0, totalClicks: 5, uniqueClicks: 3 }]
461
+ ```
462
+
463
+ Unique counts are based on distinct IP addresses.
464
+
465
+ ### Limitations
466
+
467
+ - **Open tracking is approximate**: Email clients like Apple Mail Privacy Protection may pre-fetch images, inflating open counts.
468
+ - Tracking auto-disables when `enableHistory: false` (FK dependency) or `tracking.secret` is not set.
469
+
351
470
  ## API Reference
352
471
 
353
472
  ### Exports
@@ -399,6 +518,14 @@ export {
399
518
  findNotifications,
400
519
  getNotificationStats,
401
520
 
521
+ // Tracking
522
+ trackingRouter,
523
+ processTrackingHtml,
524
+ getTrackingStats,
525
+ getEngagementStats,
526
+ getClickDetails,
527
+ isTrackingEnabled,
528
+
402
529
  // Jobs
403
530
  notificationJobRouter,
404
531
  };
@@ -42,6 +42,34 @@ declare const notificationEnvSchema: {
42
42
  } & {
43
43
  key: "SPFN_NOTIFICATION_SLACK_WEBHOOK_URL";
44
44
  };
45
+ SPFN_NOTIFICATION_TRACKING_ENABLED: {
46
+ description: string;
47
+ default: string;
48
+ required: boolean;
49
+ examples: string[];
50
+ type: "string";
51
+ validator: (value: string) => string;
52
+ } & {
53
+ key: "SPFN_NOTIFICATION_TRACKING_ENABLED";
54
+ };
55
+ SPFN_NOTIFICATION_TRACKING_SECRET: {
56
+ description: string;
57
+ required: boolean;
58
+ sensitive: boolean;
59
+ type: "string";
60
+ validator: (value: string) => string;
61
+ } & {
62
+ key: "SPFN_NOTIFICATION_TRACKING_SECRET";
63
+ };
64
+ SPFN_NOTIFICATION_TRACKING_BASE_URL: {
65
+ description: string;
66
+ required: boolean;
67
+ examples: string[];
68
+ type: "string";
69
+ validator: (value: string) => string;
70
+ } & {
71
+ key: "SPFN_NOTIFICATION_TRACKING_BASE_URL";
72
+ };
45
73
  AWS_REGION: {
46
74
  description: string;
47
75
  default: string;
@@ -111,6 +139,34 @@ declare const env: _spfn_core_env.InferEnvType<{
111
139
  } & {
112
140
  key: "SPFN_NOTIFICATION_SLACK_WEBHOOK_URL";
113
141
  };
142
+ SPFN_NOTIFICATION_TRACKING_ENABLED: {
143
+ description: string;
144
+ default: string;
145
+ required: boolean;
146
+ examples: string[];
147
+ type: "string";
148
+ validator: (value: string) => string;
149
+ } & {
150
+ key: "SPFN_NOTIFICATION_TRACKING_ENABLED";
151
+ };
152
+ SPFN_NOTIFICATION_TRACKING_SECRET: {
153
+ description: string;
154
+ required: boolean;
155
+ sensitive: boolean;
156
+ type: "string";
157
+ validator: (value: string) => string;
158
+ } & {
159
+ key: "SPFN_NOTIFICATION_TRACKING_SECRET";
160
+ };
161
+ SPFN_NOTIFICATION_TRACKING_BASE_URL: {
162
+ description: string;
163
+ required: boolean;
164
+ examples: string[];
165
+ type: "string";
166
+ validator: (value: string) => string;
167
+ } & {
168
+ key: "SPFN_NOTIFICATION_TRACKING_BASE_URL";
169
+ };
114
170
  AWS_REGION: {
115
171
  description: string;
116
172
  default: string;
@@ -164,6 +220,17 @@ interface NotificationConfig {
164
220
  * @default false
165
221
  */
166
222
  enableHistory?: boolean;
223
+ /**
224
+ * Email engagement tracking configuration
225
+ */
226
+ tracking?: {
227
+ /** Enable tracking (default: false) */
228
+ enabled?: boolean;
229
+ /** HMAC secret key for token signing */
230
+ secret?: string;
231
+ /** Base URL for tracking endpoints */
232
+ baseUrl?: string;
233
+ };
167
234
  }
168
235
  /**
169
236
  * Configure notification settings
@@ -193,5 +260,17 @@ declare function getAppName(): string;
193
260
  * Check if history tracking is enabled
194
261
  */
195
262
  declare function isHistoryEnabled(): boolean;
263
+ /**
264
+ * Check if tracking is enabled (config → env → false)
265
+ */
266
+ declare function isTrackingEnabled(): boolean;
267
+ /**
268
+ * Get tracking HMAC secret
269
+ */
270
+ declare function getTrackingSecret(): string | undefined;
271
+ /**
272
+ * Get tracking base URL
273
+ */
274
+ declare function getTrackingBaseUrl(): string | undefined;
196
275
 
197
- export { type NotificationConfig, configureNotification, env, getAppName, getEmailFrom, getEmailReplyTo, getNotificationConfig, getSmsDefaultCountryCode, isHistoryEnabled, notificationEnvSchema };
276
+ export { type NotificationConfig, configureNotification, env, getAppName, getEmailFrom, getEmailReplyTo, getNotificationConfig, getSmsDefaultCountryCode, getTrackingBaseUrl, getTrackingSecret, isHistoryEnabled, isTrackingEnabled, notificationEnvSchema };
@@ -37,6 +37,29 @@ var notificationEnvSchema = defineEnvSchema({
37
37
  examples: ["https://hooks.slack.com/services/xxx/xxx/xxx"]
38
38
  })
39
39
  },
40
+ // Tracking
41
+ SPFN_NOTIFICATION_TRACKING_ENABLED: {
42
+ ...envString({
43
+ description: "Enable email engagement tracking (open/click)",
44
+ default: "false",
45
+ required: false,
46
+ examples: ["true", "false"]
47
+ })
48
+ },
49
+ SPFN_NOTIFICATION_TRACKING_SECRET: {
50
+ ...envString({
51
+ description: "HMAC secret key for tracking token signing",
52
+ required: false,
53
+ sensitive: true
54
+ })
55
+ },
56
+ SPFN_NOTIFICATION_TRACKING_BASE_URL: {
57
+ ...envString({
58
+ description: "Base URL for tracking endpoints",
59
+ required: false,
60
+ examples: ["https://api.example.com"]
61
+ })
62
+ },
40
63
  // AWS (shared with other AWS services)
41
64
  AWS_REGION: {
42
65
  ...envString({
@@ -87,6 +110,18 @@ function getAppName() {
87
110
  function isHistoryEnabled() {
88
111
  return globalConfig.enableHistory ?? false;
89
112
  }
113
+ function isTrackingEnabled() {
114
+ if (globalConfig.tracking?.enabled != null) {
115
+ return globalConfig.tracking.enabled;
116
+ }
117
+ return env.SPFN_NOTIFICATION_TRACKING_ENABLED === "true";
118
+ }
119
+ function getTrackingSecret() {
120
+ return globalConfig.tracking?.secret ?? env.SPFN_NOTIFICATION_TRACKING_SECRET;
121
+ }
122
+ function getTrackingBaseUrl() {
123
+ return globalConfig.tracking?.baseUrl ?? env.SPFN_NOTIFICATION_TRACKING_BASE_URL;
124
+ }
90
125
  export {
91
126
  configureNotification,
92
127
  env,
@@ -95,7 +130,10 @@ export {
95
130
  getEmailReplyTo,
96
131
  getNotificationConfig,
97
132
  getSmsDefaultCountryCode,
133
+ getTrackingBaseUrl,
134
+ getTrackingSecret,
98
135
  isHistoryEnabled,
136
+ isTrackingEnabled,
99
137
  notificationEnvSchema
100
138
  };
101
139
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/config/index.ts","../../src/config/schema.ts"],"sourcesContent":["/**\n * @spfn/notification - Configuration\n */\n\nimport { createEnvRegistry } from '@spfn/core/env';\nimport { notificationEnvSchema } from './schema';\n\nexport { notificationEnvSchema };\n\n/**\n * Environment registry\n */\nconst registry = createEnvRegistry(notificationEnvSchema);\nexport const env = registry.validate();\n\n/**\n * Notification configuration\n */\nexport interface NotificationConfig\n{\n email?: {\n provider?: 'aws-ses' | 'sendgrid' | 'smtp';\n from?: string;\n replyTo?: string;\n };\n sms?: {\n provider?: 'aws-sns' | 'twilio';\n defaultCountryCode?: string;\n };\n slack?: {\n webhookUrl?: string;\n };\n defaults?: {\n appName?: string;\n };\n /**\n * Enable notification history tracking (requires database)\n * @default false\n */\n enableHistory?: boolean;\n}\n\nlet globalConfig: NotificationConfig = {};\n\n/**\n * Configure notification settings\n */\nexport function configureNotification(config: NotificationConfig): void\n{\n globalConfig = { ...globalConfig, ...config };\n}\n\n/**\n * Get current notification configuration\n */\nexport function getNotificationConfig(): NotificationConfig\n{\n return { ...globalConfig };\n}\n\n/**\n * Get email from address\n */\nexport function getEmailFrom(): string\n{\n return globalConfig.email?.from || env.SPFN_NOTIFICATION_EMAIL_FROM || 'noreply@example.com';\n}\n\n/**\n * Get email reply-to address\n */\nexport function getEmailReplyTo(): string | undefined\n{\n return globalConfig.email?.replyTo;\n}\n\n/**\n * Get SMS default country code\n */\nexport function getSmsDefaultCountryCode(): string\n{\n return globalConfig.sms?.defaultCountryCode || '+82';\n}\n\n/**\n * Get app name for templates\n */\nexport function getAppName(): string\n{\n return globalConfig.defaults?.appName || 'SPFN';\n}\n\n/**\n * Check if history tracking is enabled\n */\nexport function isHistoryEnabled(): boolean\n{\n return globalConfig.enableHistory ?? false;\n}\n","/**\n * @spfn/notification - Environment Schema\n */\n\nimport { defineEnvSchema, envString } from '@spfn/core/env';\n\nexport const notificationEnvSchema = defineEnvSchema({\n // Email\n SPFN_NOTIFICATION_EMAIL_PROVIDER: {\n ...envString({\n description: 'Email provider (aws-ses, sendgrid, smtp)',\n default: 'aws-ses',\n required: false,\n examples: ['aws-ses', 'sendgrid', 'smtp'],\n }),\n },\n\n SPFN_NOTIFICATION_EMAIL_FROM: {\n ...envString({\n description: 'Default sender email address',\n required: false,\n examples: ['noreply@example.com'],\n }),\n },\n\n // SMS\n SPFN_NOTIFICATION_SMS_PROVIDER: {\n ...envString({\n description: 'SMS provider (aws-sns, twilio)',\n default: 'aws-sns',\n required: false,\n examples: ['aws-sns', 'twilio'],\n }),\n },\n\n // Slack\n SPFN_NOTIFICATION_SLACK_WEBHOOK_URL: {\n ...envString({\n description: 'Slack webhook URL',\n required: false,\n examples: ['https://hooks.slack.com/services/xxx/xxx/xxx'],\n }),\n },\n\n // AWS (shared with other AWS services)\n AWS_REGION: {\n ...envString({\n description: 'AWS region',\n default: 'ap-northeast-2',\n required: false,\n examples: ['ap-northeast-2', 'us-east-1'],\n }),\n },\n\n AWS_ACCESS_KEY_ID: {\n ...envString({\n description: 'AWS access key ID',\n required: false,\n sensitive: true,\n }),\n },\n\n AWS_SECRET_ACCESS_KEY: {\n ...envString({\n description: 'AWS secret access key',\n required: false,\n sensitive: true,\n }),\n },\n});\n"],"mappings":";AAIA,SAAS,yBAAyB;;;ACAlC,SAAS,iBAAiB,iBAAiB;AAEpC,IAAM,wBAAwB,gBAAgB;AAAA;AAAA,EAEjD,kCAAkC;AAAA,IAC9B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,WAAW,YAAY,MAAM;AAAA,IAC5C,CAAC;AAAA,EACL;AAAA,EAEA,8BAA8B;AAAA,IAC1B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU,CAAC,qBAAqB;AAAA,IACpC,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,gCAAgC;AAAA,IAC5B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,WAAW,QAAQ;AAAA,IAClC,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,qCAAqC;AAAA,IACjC,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU,CAAC,8CAA8C;AAAA,IAC7D,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,YAAY;AAAA,IACR,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,kBAAkB,WAAW;AAAA,IAC5C,CAAC;AAAA,EACL;AAAA,EAEA,mBAAmB;AAAA,IACf,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,WAAW;AAAA,IACf,CAAC;AAAA,EACL;AAAA,EAEA,uBAAuB;AAAA,IACnB,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,WAAW;AAAA,IACf,CAAC;AAAA,EACL;AACJ,CAAC;;;ADzDD,IAAM,WAAW,kBAAkB,qBAAqB;AACjD,IAAM,MAAM,SAAS,SAAS;AA6BrC,IAAI,eAAmC,CAAC;AAKjC,SAAS,sBAAsB,QACtC;AACI,iBAAe,EAAE,GAAG,cAAc,GAAG,OAAO;AAChD;AAKO,SAAS,wBAChB;AACI,SAAO,EAAE,GAAG,aAAa;AAC7B;AAKO,SAAS,eAChB;AACI,SAAO,aAAa,OAAO,QAAQ,IAAI,gCAAgC;AAC3E;AAKO,SAAS,kBAChB;AACI,SAAO,aAAa,OAAO;AAC/B;AAKO,SAAS,2BAChB;AACI,SAAO,aAAa,KAAK,sBAAsB;AACnD;AAKO,SAAS,aAChB;AACI,SAAO,aAAa,UAAU,WAAW;AAC7C;AAKO,SAAS,mBAChB;AACI,SAAO,aAAa,iBAAiB;AACzC;","names":[]}
1
+ {"version":3,"sources":["../../src/config/index.ts","../../src/config/schema.ts"],"sourcesContent":["/**\n * @spfn/notification - Configuration\n */\n\nimport { createEnvRegistry } from '@spfn/core/env';\nimport { notificationEnvSchema } from './schema';\n\nexport { notificationEnvSchema };\n\n/**\n * Environment registry\n */\nconst registry = createEnvRegistry(notificationEnvSchema);\nexport const env = registry.validate();\n\n/**\n * Notification configuration\n */\nexport interface NotificationConfig\n{\n email?: {\n provider?: 'aws-ses' | 'sendgrid' | 'smtp';\n from?: string;\n replyTo?: string;\n };\n sms?: {\n provider?: 'aws-sns' | 'twilio';\n defaultCountryCode?: string;\n };\n slack?: {\n webhookUrl?: string;\n };\n defaults?: {\n appName?: string;\n };\n /**\n * Enable notification history tracking (requires database)\n * @default false\n */\n enableHistory?: boolean;\n /**\n * Email engagement tracking configuration\n */\n tracking?: {\n /** Enable tracking (default: false) */\n enabled?: boolean;\n /** HMAC secret key for token signing */\n secret?: string;\n /** Base URL for tracking endpoints */\n baseUrl?: string;\n };\n}\n\nlet globalConfig: NotificationConfig = {};\n\n/**\n * Configure notification settings\n */\nexport function configureNotification(config: NotificationConfig): void\n{\n globalConfig = { ...globalConfig, ...config };\n}\n\n/**\n * Get current notification configuration\n */\nexport function getNotificationConfig(): NotificationConfig\n{\n return { ...globalConfig };\n}\n\n/**\n * Get email from address\n */\nexport function getEmailFrom(): string\n{\n return globalConfig.email?.from || env.SPFN_NOTIFICATION_EMAIL_FROM || 'noreply@example.com';\n}\n\n/**\n * Get email reply-to address\n */\nexport function getEmailReplyTo(): string | undefined\n{\n return globalConfig.email?.replyTo;\n}\n\n/**\n * Get SMS default country code\n */\nexport function getSmsDefaultCountryCode(): string\n{\n return globalConfig.sms?.defaultCountryCode || '+82';\n}\n\n/**\n * Get app name for templates\n */\nexport function getAppName(): string\n{\n return globalConfig.defaults?.appName || 'SPFN';\n}\n\n/**\n * Check if history tracking is enabled\n */\nexport function isHistoryEnabled(): boolean\n{\n return globalConfig.enableHistory ?? false;\n}\n\n/**\n * Check if tracking is enabled (config → env → false)\n */\nexport function isTrackingEnabled(): boolean\n{\n if (globalConfig.tracking?.enabled != null)\n {\n return globalConfig.tracking.enabled;\n }\n return env.SPFN_NOTIFICATION_TRACKING_ENABLED === 'true';\n}\n\n/**\n * Get tracking HMAC secret\n */\nexport function getTrackingSecret(): string | undefined\n{\n return globalConfig.tracking?.secret ?? env.SPFN_NOTIFICATION_TRACKING_SECRET;\n}\n\n/**\n * Get tracking base URL\n */\nexport function getTrackingBaseUrl(): string | undefined\n{\n return globalConfig.tracking?.baseUrl ?? env.SPFN_NOTIFICATION_TRACKING_BASE_URL;\n}\n","/**\n * @spfn/notification - Environment Schema\n */\n\nimport { defineEnvSchema, envString } from '@spfn/core/env';\n\nexport const notificationEnvSchema = defineEnvSchema({\n // Email\n SPFN_NOTIFICATION_EMAIL_PROVIDER: {\n ...envString({\n description: 'Email provider (aws-ses, sendgrid, smtp)',\n default: 'aws-ses',\n required: false,\n examples: ['aws-ses', 'sendgrid', 'smtp'],\n }),\n },\n\n SPFN_NOTIFICATION_EMAIL_FROM: {\n ...envString({\n description: 'Default sender email address',\n required: false,\n examples: ['noreply@example.com'],\n }),\n },\n\n // SMS\n SPFN_NOTIFICATION_SMS_PROVIDER: {\n ...envString({\n description: 'SMS provider (aws-sns, twilio)',\n default: 'aws-sns',\n required: false,\n examples: ['aws-sns', 'twilio'],\n }),\n },\n\n // Slack\n SPFN_NOTIFICATION_SLACK_WEBHOOK_URL: {\n ...envString({\n description: 'Slack webhook URL',\n required: false,\n examples: ['https://hooks.slack.com/services/xxx/xxx/xxx'],\n }),\n },\n\n // Tracking\n SPFN_NOTIFICATION_TRACKING_ENABLED: {\n ...envString({\n description: 'Enable email engagement tracking (open/click)',\n default: 'false',\n required: false,\n examples: ['true', 'false'],\n }),\n },\n\n SPFN_NOTIFICATION_TRACKING_SECRET: {\n ...envString({\n description: 'HMAC secret key for tracking token signing',\n required: false,\n sensitive: true,\n }),\n },\n\n SPFN_NOTIFICATION_TRACKING_BASE_URL: {\n ...envString({\n description: 'Base URL for tracking endpoints',\n required: false,\n examples: ['https://api.example.com'],\n }),\n },\n\n // AWS (shared with other AWS services)\n AWS_REGION: {\n ...envString({\n description: 'AWS region',\n default: 'ap-northeast-2',\n required: false,\n examples: ['ap-northeast-2', 'us-east-1'],\n }),\n },\n\n AWS_ACCESS_KEY_ID: {\n ...envString({\n description: 'AWS access key ID',\n required: false,\n sensitive: true,\n }),\n },\n\n AWS_SECRET_ACCESS_KEY: {\n ...envString({\n description: 'AWS secret access key',\n required: false,\n sensitive: true,\n }),\n },\n});\n"],"mappings":";AAIA,SAAS,yBAAyB;;;ACAlC,SAAS,iBAAiB,iBAAiB;AAEpC,IAAM,wBAAwB,gBAAgB;AAAA;AAAA,EAEjD,kCAAkC;AAAA,IAC9B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,WAAW,YAAY,MAAM;AAAA,IAC5C,CAAC;AAAA,EACL;AAAA,EAEA,8BAA8B;AAAA,IAC1B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU,CAAC,qBAAqB;AAAA,IACpC,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,gCAAgC;AAAA,IAC5B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,WAAW,QAAQ;AAAA,IAClC,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,qCAAqC;AAAA,IACjC,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU,CAAC,8CAA8C;AAAA,IAC7D,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,oCAAoC;AAAA,IAChC,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,QAAQ,OAAO;AAAA,IAC9B,CAAC;AAAA,EACL;AAAA,EAEA,mCAAmC;AAAA,IAC/B,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,WAAW;AAAA,IACf,CAAC;AAAA,EACL;AAAA,EAEA,qCAAqC;AAAA,IACjC,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,UAAU,CAAC,yBAAyB;AAAA,IACxC,CAAC;AAAA,EACL;AAAA;AAAA,EAGA,YAAY;AAAA,IACR,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,SAAS;AAAA,MACT,UAAU;AAAA,MACV,UAAU,CAAC,kBAAkB,WAAW;AAAA,IAC5C,CAAC;AAAA,EACL;AAAA,EAEA,mBAAmB;AAAA,IACf,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,WAAW;AAAA,IACf,CAAC;AAAA,EACL;AAAA,EAEA,uBAAuB;AAAA,IACnB,GAAG,UAAU;AAAA,MACT,aAAa;AAAA,MACb,UAAU;AAAA,MACV,WAAW;AAAA,IACf,CAAC;AAAA,EACL;AACJ,CAAC;;;ADnFD,IAAM,WAAW,kBAAkB,qBAAqB;AACjD,IAAM,MAAM,SAAS,SAAS;AAwCrC,IAAI,eAAmC,CAAC;AAKjC,SAAS,sBAAsB,QACtC;AACI,iBAAe,EAAE,GAAG,cAAc,GAAG,OAAO;AAChD;AAKO,SAAS,wBAChB;AACI,SAAO,EAAE,GAAG,aAAa;AAC7B;AAKO,SAAS,eAChB;AACI,SAAO,aAAa,OAAO,QAAQ,IAAI,gCAAgC;AAC3E;AAKO,SAAS,kBAChB;AACI,SAAO,aAAa,OAAO;AAC/B;AAKO,SAAS,2BAChB;AACI,SAAO,aAAa,KAAK,sBAAsB;AACnD;AAKO,SAAS,aAChB;AACI,SAAO,aAAa,UAAU,WAAW;AAC7C;AAKO,SAAS,mBAChB;AACI,SAAO,aAAa,iBAAiB;AACzC;AAKO,SAAS,oBAChB;AACI,MAAI,aAAa,UAAU,WAAW,MACtC;AACI,WAAO,aAAa,SAAS;AAAA,EACjC;AACA,SAAO,IAAI,uCAAuC;AACtD;AAKO,SAAS,oBAChB;AACI,SAAO,aAAa,UAAU,UAAU,IAAI;AAChD;AAKO,SAAS,qBAChB;AACI,SAAO,aAAa,UAAU,WAAW,IAAI;AACjD;","names":[]}
@@ -61,6 +61,11 @@ interface SendEmailParams {
61
61
  * Reply-to address
62
62
  */
63
63
  replyTo?: string;
64
+ /**
65
+ * Enable/disable engagement tracking for this email.
66
+ * When undefined, falls back to global tracking config.
67
+ */
68
+ tracking?: boolean;
64
69
  }
65
70
  /**
66
71
  * Email provider interface
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { g as EmailTemplateContent, N as NotificationChannel, S as SendEmailParams, a as SendResult, b as SendSMSParams, d as SendSlackParams, i as SlackTemplateContent, h as SmsTemplateContent, f as TemplateData, T as TemplateDefinition } from './index-BAe1ZBYQ.js';
1
+ export { g as EmailTemplateContent, N as NotificationChannel, S as SendEmailParams, a as SendResult, b as SendSMSParams, d as SendSlackParams, i as SlackTemplateContent, h as SmsTemplateContent, f as TemplateData, T as TemplateDefinition } from './index-DHgXI5Eq.js';
package/dist/server.d.ts CHANGED
@@ -1,8 +1,10 @@
1
- export { NotificationConfig, configureNotification, getAppName, getEmailFrom, getEmailReplyTo, getNotificationConfig, getSmsDefaultCountryCode } from './config/index.js';
2
- import { S as SendEmailParams, a as SendResult, E as EmailProvider, b as SendSMSParams, c as SMSProvider, d as SendSlackParams, e as SlackProvider, T as TemplateDefinition, f as TemplateData, N as NotificationChannel$1, R as RenderedTemplate } from './index-BAe1ZBYQ.js';
3
- export { g as EmailTemplateContent, i as SlackTemplateContent, h as SmsTemplateContent } from './index-BAe1ZBYQ.js';
1
+ export { NotificationConfig, configureNotification, getAppName, getEmailFrom, getEmailReplyTo, getNotificationConfig, getSmsDefaultCountryCode, getTrackingBaseUrl, getTrackingSecret, isTrackingEnabled } from './config/index.js';
2
+ import { S as SendEmailParams, a as SendResult, E as EmailProvider, b as SendSMSParams, c as SMSProvider, d as SendSlackParams, e as SlackProvider, T as TemplateDefinition, f as TemplateData, N as NotificationChannel$1, R as RenderedTemplate } from './index-DHgXI5Eq.js';
3
+ export { g as EmailTemplateContent, i as SlackTemplateContent, h as SmsTemplateContent } from './index-DHgXI5Eq.js';
4
4
  import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
5
5
  import * as _spfn_core_job from '@spfn/core/job';
6
+ import * as _spfn_core_route from '@spfn/core/route';
7
+ import * as _sinclair_typebox from '@sinclair/typebox';
6
8
  import '@spfn/core/env';
7
9
 
8
10
  /**
@@ -510,6 +512,184 @@ declare const notifications: drizzle_orm_pg_core.PgTableWithColumns<{
510
512
  type Notification = typeof notifications.$inferSelect;
511
513
  type NewNotification = typeof notifications.$inferInsert;
512
514
 
515
+ /**
516
+ * @spfn/notification - Tracking Events Entity
517
+ *
518
+ * Stores email engagement tracking events (opens, clicks)
519
+ */
520
+ /**
521
+ * Tracking event types
522
+ */
523
+ declare const TRACKING_EVENT_TYPES: readonly ["open", "click"];
524
+ type TrackingEventType = typeof TRACKING_EVENT_TYPES[number];
525
+ /**
526
+ * Tracking events table - stores email engagement events
527
+ */
528
+ declare const trackingEvents: drizzle_orm_pg_core.PgTableWithColumns<{
529
+ name: "tracking_events";
530
+ schema: string;
531
+ columns: {
532
+ createdAt: drizzle_orm_pg_core.PgColumn<{
533
+ name: "created_at";
534
+ tableName: "tracking_events";
535
+ dataType: "date";
536
+ columnType: "PgTimestamp";
537
+ data: Date;
538
+ driverParam: string;
539
+ notNull: true;
540
+ hasDefault: true;
541
+ isPrimaryKey: false;
542
+ isAutoincrement: false;
543
+ hasRuntimeDefault: false;
544
+ enumValues: undefined;
545
+ baseColumn: never;
546
+ identity: undefined;
547
+ generated: undefined;
548
+ }, {}, {}>;
549
+ updatedAt: drizzle_orm_pg_core.PgColumn<{
550
+ name: "updated_at";
551
+ tableName: "tracking_events";
552
+ dataType: "date";
553
+ columnType: "PgTimestamp";
554
+ data: Date;
555
+ driverParam: string;
556
+ notNull: true;
557
+ hasDefault: true;
558
+ isPrimaryKey: false;
559
+ isAutoincrement: false;
560
+ hasRuntimeDefault: false;
561
+ enumValues: undefined;
562
+ baseColumn: never;
563
+ identity: undefined;
564
+ generated: undefined;
565
+ }, {}, {}>;
566
+ id: drizzle_orm_pg_core.PgColumn<{
567
+ name: "id";
568
+ tableName: "tracking_events";
569
+ dataType: "number";
570
+ columnType: "PgBigSerial53";
571
+ data: number;
572
+ driverParam: number;
573
+ notNull: true;
574
+ hasDefault: true;
575
+ isPrimaryKey: true;
576
+ isAutoincrement: false;
577
+ hasRuntimeDefault: false;
578
+ enumValues: undefined;
579
+ baseColumn: never;
580
+ identity: undefined;
581
+ generated: undefined;
582
+ }, {}, {}>;
583
+ notificationId: drizzle_orm_pg_core.PgColumn<{
584
+ name: "notification_id";
585
+ tableName: "tracking_events";
586
+ dataType: "number";
587
+ columnType: "PgInteger";
588
+ data: number;
589
+ driverParam: string | number;
590
+ notNull: true;
591
+ hasDefault: false;
592
+ isPrimaryKey: false;
593
+ isAutoincrement: false;
594
+ hasRuntimeDefault: false;
595
+ enumValues: undefined;
596
+ baseColumn: never;
597
+ identity: undefined;
598
+ generated: undefined;
599
+ }, {}, {}>;
600
+ type: drizzle_orm_pg_core.PgColumn<{
601
+ name: "type";
602
+ tableName: "tracking_events";
603
+ dataType: "string";
604
+ columnType: "PgText";
605
+ data: "open" | "click";
606
+ driverParam: string;
607
+ notNull: true;
608
+ hasDefault: false;
609
+ isPrimaryKey: false;
610
+ isAutoincrement: false;
611
+ hasRuntimeDefault: false;
612
+ enumValues: ["open", "click"];
613
+ baseColumn: never;
614
+ identity: undefined;
615
+ generated: undefined;
616
+ }, {}, {}>;
617
+ linkUrl: drizzle_orm_pg_core.PgColumn<{
618
+ name: "link_url";
619
+ tableName: "tracking_events";
620
+ dataType: "string";
621
+ columnType: "PgText";
622
+ data: string;
623
+ driverParam: string;
624
+ notNull: false;
625
+ hasDefault: false;
626
+ isPrimaryKey: false;
627
+ isAutoincrement: false;
628
+ hasRuntimeDefault: false;
629
+ enumValues: [string, ...string[]];
630
+ baseColumn: never;
631
+ identity: undefined;
632
+ generated: undefined;
633
+ }, {}, {}>;
634
+ linkIndex: drizzle_orm_pg_core.PgColumn<{
635
+ name: "link_index";
636
+ tableName: "tracking_events";
637
+ dataType: "number";
638
+ columnType: "PgInteger";
639
+ data: number;
640
+ driverParam: string | number;
641
+ notNull: false;
642
+ hasDefault: false;
643
+ isPrimaryKey: false;
644
+ isAutoincrement: false;
645
+ hasRuntimeDefault: false;
646
+ enumValues: undefined;
647
+ baseColumn: never;
648
+ identity: undefined;
649
+ generated: undefined;
650
+ }, {}, {}>;
651
+ ipAddress: drizzle_orm_pg_core.PgColumn<{
652
+ name: "ip_address";
653
+ tableName: "tracking_events";
654
+ dataType: "string";
655
+ columnType: "PgText";
656
+ data: string;
657
+ driverParam: string;
658
+ notNull: false;
659
+ hasDefault: false;
660
+ isPrimaryKey: false;
661
+ isAutoincrement: false;
662
+ hasRuntimeDefault: false;
663
+ enumValues: [string, ...string[]];
664
+ baseColumn: never;
665
+ identity: undefined;
666
+ generated: undefined;
667
+ }, {}, {}>;
668
+ userAgent: drizzle_orm_pg_core.PgColumn<{
669
+ name: "user_agent";
670
+ tableName: "tracking_events";
671
+ dataType: "string";
672
+ columnType: "PgText";
673
+ data: string;
674
+ driverParam: string;
675
+ notNull: false;
676
+ hasDefault: false;
677
+ isPrimaryKey: false;
678
+ isAutoincrement: false;
679
+ hasRuntimeDefault: false;
680
+ enumValues: [string, ...string[]];
681
+ baseColumn: never;
682
+ identity: undefined;
683
+ generated: undefined;
684
+ }, {}, {}>;
685
+ };
686
+ dialect: "pg";
687
+ }>;
688
+ /**
689
+ * Type inference
690
+ */
691
+ type TrackingEvent = typeof trackingEvents.$inferSelect;
692
+
513
693
  /**
514
694
  * @spfn/notification - Notification Service
515
695
  *
@@ -777,4 +957,99 @@ interface ErrorSlackOptions {
777
957
  */
778
958
  declare function createErrorSlackNotifier(options?: ErrorSlackOptions): (err: Error, ctx: ErrorContext) => Promise<void>;
779
959
 
780
- export { type CancelResult, EmailProvider, type ErrorSlackOptions, type FindNotificationsOptions, NOTIFICATION_CHANNELS, NOTIFICATION_STATUSES, type NewNotification, type Notification, NotificationChannel$1 as NotificationChannel, type NotificationStats, type NotificationStatus, SMSProvider, type ScheduleOptions, type ScheduleResult, SendEmailParams, SendResult, SendSMSParams, SendSlackParams, SlackProvider, TemplateData, TemplateDefinition, cancelNotification, cancelNotificationsByReference, cancelScheduledNotification, countNotifications, createErrorSlackNotifier, createNotificationRecord, createScheduledNotification, findNotificationByJobId, findNotifications, findScheduledNotifications, getNotificationStats, getTemplate, getTemplateNames, hasTemplate, markNotificationFailed, markNotificationPending, markNotificationSent, notificationJobRouter, notifications, registerBuiltinTemplates, registerEmailProvider, registerFilter, registerSMSProvider, registerSlackProvider, registerTemplate, renderTemplate, scheduleEmail, scheduleSMS, sendEmail, sendEmailBulk, sendSMS, sendSMSBulk, sendScheduledEmailJob, sendScheduledSmsJob, sendSlack, sendSlackBulk, updateNotificationJobId };
960
+ /**
961
+ * Tracking router
962
+ */
963
+ declare const trackingRouter: _spfn_core_route.Router<{
964
+ trackOpen: _spfn_core_route.RouteDef<{
965
+ params: _sinclair_typebox.TObject<{
966
+ token: _sinclair_typebox.TString;
967
+ }>;
968
+ }, {}, Response>;
969
+ trackClick: _spfn_core_route.RouteDef<{
970
+ params: _sinclair_typebox.TObject<{
971
+ token: _sinclair_typebox.TString;
972
+ }>;
973
+ query: _sinclair_typebox.TObject<{
974
+ url: _sinclair_typebox.TString;
975
+ }>;
976
+ }, {}, Response>;
977
+ }>;
978
+
979
+ /**
980
+ * @spfn/notification - Tracking HTML Processor
981
+ *
982
+ * Injects open tracking pixel and wraps links for click tracking.
983
+ */
984
+ interface ProcessTrackingOptions {
985
+ notificationId: number;
986
+ baseUrl: string;
987
+ }
988
+ interface TrackedLink {
989
+ index: number;
990
+ url: string;
991
+ }
992
+ interface ProcessTrackingResult {
993
+ html: string;
994
+ trackedLinks: TrackedLink[];
995
+ }
996
+ /**
997
+ * Process HTML to inject tracking pixel and wrap links
998
+ *
999
+ * 1. Wraps <a href="..."> links with click tracking redirect URLs
1000
+ * 2. Inserts a 1x1 transparent GIF tracking pixel before </body>
1001
+ */
1002
+ declare function processTrackingHtml(html: string, options: ProcessTrackingOptions): ProcessTrackingResult;
1003
+
1004
+ /**
1005
+ * @spfn/notification - Tracking Service
1006
+ *
1007
+ * Records engagement events and provides analytics queries.
1008
+ */
1009
+
1010
+ /**
1011
+ * Tracking stats for a single notification
1012
+ */
1013
+ interface TrackingStats {
1014
+ totalOpens: number;
1015
+ uniqueOpens: number;
1016
+ totalClicks: number;
1017
+ uniqueClicks: number;
1018
+ }
1019
+ /**
1020
+ * Get tracking stats for a specific notification
1021
+ */
1022
+ declare function getTrackingStats(notificationId: number): Promise<TrackingStats>;
1023
+ /**
1024
+ * Engagement stats across notifications
1025
+ */
1026
+ interface EngagementStats {
1027
+ sent: number;
1028
+ opened: number;
1029
+ clicked: number;
1030
+ openRate: number;
1031
+ clickRate: number;
1032
+ }
1033
+ /**
1034
+ * Get engagement stats with optional filters
1035
+ */
1036
+ declare function getEngagementStats(options?: {
1037
+ channel?: NotificationChannel;
1038
+ from?: Date;
1039
+ to?: Date;
1040
+ }): Promise<EngagementStats>;
1041
+ /**
1042
+ * Click detail for a specific link
1043
+ */
1044
+ interface ClickDetail {
1045
+ linkUrl: string;
1046
+ linkIndex: number;
1047
+ totalClicks: number;
1048
+ uniqueClicks: number;
1049
+ }
1050
+ /**
1051
+ * Get click details for a specific notification
1052
+ */
1053
+ declare function getClickDetails(notificationId: number): Promise<ClickDetail[]>;
1054
+
1055
+ export { type CancelResult, type ClickDetail, EmailProvider, type EngagementStats, type ErrorSlackOptions, type FindNotificationsOptions, NOTIFICATION_CHANNELS, NOTIFICATION_STATUSES, type NewNotification, type Notification, NotificationChannel$1 as NotificationChannel, type NotificationStats, type NotificationStatus, SMSProvider, type ScheduleOptions, type ScheduleResult, SendEmailParams, SendResult, SendSMSParams, SendSlackParams, SlackProvider, TRACKING_EVENT_TYPES, TemplateData, TemplateDefinition, type TrackingEvent, type TrackingEventType, type TrackingStats, cancelNotification, cancelNotificationsByReference, cancelScheduledNotification, countNotifications, createErrorSlackNotifier, createNotificationRecord, createScheduledNotification, findNotificationByJobId, findNotifications, findScheduledNotifications, getClickDetails, getEngagementStats, getNotificationStats, getTemplate, getTemplateNames, getTrackingStats, hasTemplate, markNotificationFailed, markNotificationPending, markNotificationSent, notificationJobRouter, notifications, processTrackingHtml, registerBuiltinTemplates, registerEmailProvider, registerFilter, registerSMSProvider, registerSlackProvider, registerTemplate, renderTemplate, scheduleEmail, scheduleSMS, sendEmail, sendEmailBulk, sendSMS, sendSMSBulk, sendScheduledEmailJob, sendScheduledSmsJob, sendSlack, sendSlackBulk, trackingEvents, trackingRouter, updateNotificationJobId };