notifkit 0.1.2 → 0.1.4

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.
Files changed (95) hide show
  1. package/README.md +130 -122
  2. package/dist/index.d.mts +196 -132
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +3 -2
  32. package/src/client.ts +412 -0
  33. package/src/config/index.ts +107 -0
  34. package/src/contracts/common.ts +28 -0
  35. package/src/contracts/envelope.ts +31 -0
  36. package/src/contracts/events/notification-ai-pending.ts +18 -0
  37. package/src/contracts/events/notification-canceled.ts +7 -0
  38. package/src/contracts/events/notification-created.ts +14 -0
  39. package/src/contracts/events/notification-delivered.ts +17 -0
  40. package/src/contracts/events/notification-dispatched.ts +45 -0
  41. package/src/contracts/events/notification-enriched.ts +46 -0
  42. package/src/contracts/events/notification-failed.ts +19 -0
  43. package/src/contracts/events/notification-requested.ts +36 -0
  44. package/src/contracts/events/notification-scheduled.ts +9 -0
  45. package/src/contracts/events/notification-skipped.ts +9 -0
  46. package/src/contracts/helpers.ts +21 -0
  47. package/src/contracts/index.ts +46 -0
  48. package/src/contracts/metadata.ts +10 -0
  49. package/src/contracts/registry.ts +88 -0
  50. package/src/contracts/sdk.ts +242 -0
  51. package/src/contracts/streams.ts +62 -0
  52. package/src/db/index.ts +69 -0
  53. package/src/db/schema.ts +412 -0
  54. package/src/idempotency/index.ts +50 -0
  55. package/src/index.ts +19 -0
  56. package/src/logger/index.ts +60 -0
  57. package/src/metrics/index.ts +53 -0
  58. package/src/queue/index.ts +501 -0
  59. package/src/rate-limiter/index.ts +210 -0
  60. package/src/redis/index.ts +89 -0
  61. package/src/repositories/index.ts +1246 -0
  62. package/src/server.ts +277 -0
  63. package/src/services/ai/main.ts +404 -0
  64. package/src/services/api/handlers.ts +1734 -0
  65. package/src/services/api/http.ts +64 -0
  66. package/src/services/api/main.ts +693 -0
  67. package/src/services/api/router.ts +82 -0
  68. package/src/services/delivery/main.ts +842 -0
  69. package/src/services/delivery/throttle.ts +71 -0
  70. package/src/services/engine/main.ts +827 -0
  71. package/src/services/enricher/main.ts +594 -0
  72. package/src/services/events/main.ts +365 -0
  73. package/src/services/scheduler/main.ts +319 -0
  74. package/src/services/workflow/main.ts +627 -0
  75. package/src/shared/batch-processor.ts +67 -0
  76. package/src/shared/cache.ts +47 -0
  77. package/src/shared/circuit-breaker.ts +74 -0
  78. package/src/shared/dataloader.ts +41 -0
  79. package/src/shared/events.ts +3 -0
  80. package/src/shared/index.ts +39 -0
  81. package/src/shared/semaphore.ts +33 -0
  82. package/src/shared/utils.ts +64 -0
  83. package/src/templates/cache.ts +32 -0
  84. package/src/templates/index.ts +69 -0
  85. package/src/templates/render.ts +128 -0
  86. package/src/transport/index.ts +96 -0
  87. package/src/unsubscribe/index.ts +127 -0
  88. package/src/workers/health.ts +31 -0
  89. package/src/workers/index.ts +266 -0
  90. package/src/workflows/index.ts +2 -0
  91. package/src/workflows/registry.ts +21 -0
  92. package/src/workflows/sdk.ts +106 -0
  93. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  94. package/dist/main-CCfc45ev.mjs.map +0 -1
  95. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,47 @@
1
+ export class LRUCache<K, V> {
2
+ private cache = new Map<K, { value: V; expiresAt: number }>();
3
+ private readonly maxSize: number;
4
+ private readonly defaultTtlMs: number;
5
+
6
+ constructor(maxSize: number = 1000, defaultTtlMs: number = 5 * 60 * 1000) {
7
+ this.maxSize = maxSize;
8
+ this.defaultTtlMs = defaultTtlMs;
9
+ }
10
+
11
+ get(key: K): V | undefined {
12
+ const item = this.cache.get(key);
13
+ if (!item) return undefined;
14
+
15
+ if (Date.now() > item.expiresAt) {
16
+ this.cache.delete(key);
17
+ return undefined;
18
+ }
19
+
20
+ // Refresh position to make it most recently used
21
+ this.cache.delete(key);
22
+ this.cache.set(key, item);
23
+ return item.value;
24
+ }
25
+
26
+ set(key: K, value: V, ttlMs: number = this.defaultTtlMs): void {
27
+ if (this.cache.has(key)) {
28
+ this.cache.delete(key);
29
+ } else if (this.cache.size >= this.maxSize) {
30
+ // Delete the oldest item (first inserted)
31
+ const oldestKey = this.cache.keys().next().value;
32
+ if (oldestKey !== undefined) {
33
+ this.cache.delete(oldestKey);
34
+ }
35
+ }
36
+
37
+ this.cache.set(key, { value, expiresAt: Date.now() + ttlMs });
38
+ }
39
+
40
+ delete(key: K): void {
41
+ this.cache.delete(key);
42
+ }
43
+
44
+ clear(): void {
45
+ this.cache.clear();
46
+ }
47
+ }
@@ -0,0 +1,74 @@
1
+ export interface CircuitBreakerOptions {
2
+ failureThreshold: number;
3
+ resetTimeoutMs: number;
4
+ }
5
+
6
+ type State = "CLOSED" | "OPEN" | "HALF_OPEN";
7
+
8
+ export class CircuitBreaker {
9
+ private state: State = "CLOSED";
10
+ private failures = 0;
11
+ private nextAttemptAt = 0;
12
+ /** True while one caller is testing whether the dependency has recovered. */
13
+ private probeInFlight = false;
14
+ private readonly threshold: number;
15
+ private readonly timeout: number;
16
+
17
+ constructor(options: CircuitBreakerOptions) {
18
+ this.threshold = options.failureThreshold;
19
+ this.timeout = options.resetTimeoutMs;
20
+ }
21
+
22
+ async execute<T>(action: () => Promise<T>): Promise<T> {
23
+ // Only one caller gets to find out whether the dependency is back. Letting
24
+ // the whole waiting crowd through on the first tick after the timeout is
25
+ // how a struggling provider gets knocked over again the moment it recovers.
26
+ let isProbe = false;
27
+
28
+ if (this.state === "OPEN") {
29
+ if (Date.now() > this.nextAttemptAt && !this.probeInFlight) {
30
+ this.state = "HALF_OPEN";
31
+ this.probeInFlight = true;
32
+ isProbe = true;
33
+ } else {
34
+ throw new Error("Circuit breaker is OPEN");
35
+ }
36
+ } else if (this.state === "HALF_OPEN") {
37
+ if (this.probeInFlight) {
38
+ throw new Error("Circuit breaker is OPEN");
39
+ }
40
+ this.probeInFlight = true;
41
+ isProbe = true;
42
+ }
43
+
44
+ try {
45
+ const result = await action();
46
+ this.onSuccess();
47
+ return result;
48
+ } catch (err) {
49
+ this.onFailure();
50
+ throw err;
51
+ } finally {
52
+ if (isProbe) this.probeInFlight = false;
53
+ }
54
+ }
55
+
56
+ private onSuccess() {
57
+ this.failures = 0;
58
+ this.state = "CLOSED";
59
+ }
60
+
61
+ private onFailure() {
62
+ this.failures++;
63
+ if (this.failures >= this.threshold) {
64
+ this.state = "OPEN";
65
+ // Restart the clock so the next probe waits a full timeout rather than
66
+ // firing immediately off the previous deadline.
67
+ this.nextAttemptAt = Date.now() + this.timeout;
68
+ }
69
+ }
70
+
71
+ getState(): State {
72
+ return this.state;
73
+ }
74
+ }
@@ -0,0 +1,41 @@
1
+ export class DataLoader<K, V> {
2
+ private keys: K[] = [];
3
+ private promises: Array<{ resolve: (value: V | Error) => void }> = [];
4
+ private currentTick: Promise<void> | null = null;
5
+
6
+ constructor(private readonly batchLoadFn: (keys: K[]) => Promise<(V | Error)[]>) {}
7
+
8
+ load(key: K): Promise<V> {
9
+ return new Promise((resolve, reject) => {
10
+ this.keys.push(key);
11
+ this.promises.push({
12
+ resolve: (value) => {
13
+ if (value instanceof Error) reject(value);
14
+ else resolve(value);
15
+ },
16
+ });
17
+
18
+ if (!this.currentTick) {
19
+ this.currentTick = Promise.resolve().then(() => {
20
+ const keysToLoad = this.keys;
21
+ const currentPromises = this.promises;
22
+ this.keys = [];
23
+ this.promises = [];
24
+ this.currentTick = null;
25
+
26
+ this.batchLoadFn(keysToLoad)
27
+ .then((results) => {
28
+ for (let i = 0; i < currentPromises.length; i++) {
29
+ currentPromises[i]!.resolve(results[i] as V | Error);
30
+ }
31
+ })
32
+ .catch((err) => {
33
+ for (const p of currentPromises) {
34
+ p.resolve(err);
35
+ }
36
+ });
37
+ });
38
+ }
39
+ });
40
+ }
41
+ }
@@ -0,0 +1,3 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ export const globalEmitter = new EventEmitter();
@@ -0,0 +1,39 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export function generateId(): string {
4
+ return randomUUID();
5
+ }
6
+
7
+ export function sleep(ms: number): Promise<void> {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ }
10
+
11
+ export class AppError extends Error {
12
+ readonly code: string;
13
+
14
+ constructor(message: string, code: string, options?: ErrorOptions) {
15
+ super(message, options);
16
+ this.name = "AppError";
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ export class ValidationError extends AppError {
22
+ readonly fields?: Record<string, string[]>;
23
+
24
+ constructor(message: string, fields?: Record<string, string[]>, options?: ErrorOptions) {
25
+ super(message, "VALIDATION_ERROR", options);
26
+ this.name = "ValidationError";
27
+ this.fields = fields;
28
+ }
29
+ }
30
+
31
+ export * from "./events.js";
32
+ export * from "./cache.js";
33
+ export * from "./utils.js";
34
+ export * from "./semaphore.js";
35
+ export * from "./batch-processor.js";
36
+ export * from "./cache.js";
37
+ export * from "./circuit-breaker.js";
38
+ export * from "./dataloader.js";
39
+ export { type WorkerOptions } from "@/workers/index.js";
@@ -0,0 +1,33 @@
1
+ export class AsyncSemaphore {
2
+ private count = 0;
3
+ private queue: Array<() => void> = [];
4
+
5
+ constructor(private readonly max: number) {}
6
+
7
+ async acquire(): Promise<void> {
8
+ if (this.count < this.max) {
9
+ this.count++;
10
+ return Promise.resolve();
11
+ }
12
+ return new Promise<void>((resolve) => {
13
+ this.queue.push(resolve);
14
+ });
15
+ }
16
+
17
+ release(): void {
18
+ if (this.queue.length > 0) {
19
+ // Hand the permit straight over rather than decrementing and letting the
20
+ // next acquire take it back — occupancy is unchanged either way.
21
+ const next = this.queue.shift()!;
22
+ next();
23
+ } else if (this.count > 0) {
24
+ this.count--;
25
+ }
26
+ // A release with nothing held is a caller bug, but silently going negative
27
+ // turns it into over-admission later, which is far harder to trace back.
28
+ }
29
+
30
+ get activeCount(): number {
31
+ return this.count;
32
+ }
33
+ }
@@ -0,0 +1,64 @@
1
+ export function getPriorityBucket(
2
+ priority: string | undefined,
3
+ ): "critical" | "high" | "normal" | "low" {
4
+ const p = priority || "normal";
5
+ return p === "critical" || p === "high" ? "critical" : p === "low" ? "low" : "normal";
6
+ }
7
+
8
+ /**
9
+ * Canonical form of a destination, for suppression lookups.
10
+ *
11
+ * Both the writer (the provider webhook) and the reader (the engine's
12
+ * pre-dispatch gate) must agree on this, or an unsubscribe recorded as
13
+ * `Bob@Example.com` will not match a send addressed to `bob@example.com` and
14
+ * the person keeps receiving mail. Case folding is safe for email domains and
15
+ * for the local part in every mailbox provider in practice; phone numbers and
16
+ * push tokens are case-sensitive and are only trimmed.
17
+ */
18
+ export function normaliseTarget(target: string): string {
19
+ const trimmed = target.trim();
20
+ return trimmed.includes("@") ? trimmed.toLowerCase() : trimmed;
21
+ }
22
+
23
+ export const LUA_SCHEDULER_POLL = `
24
+ local key = KEYS[1]
25
+ local maxScore = tonumber(ARGV[1])
26
+ local limit = tonumber(ARGV[2])
27
+ local visibilityTimeout = tonumber(ARGV[3]) or 0
28
+ local tasks = redis.call('ZRANGE', key, 0, maxScore, 'BYSCORE', 'LIMIT', 0, limit)
29
+ if #tasks > 0 then
30
+ for i, task in ipairs(tasks) do
31
+ redis.call('ZADD', key, maxScore + visibilityTimeout, task)
32
+ end
33
+ end
34
+ return tasks
35
+ `;
36
+
37
+ export const LUA_SCHEDULER_CLAIM = `
38
+ local payloadKey = KEYS[1]
39
+ local claimedKey = KEYS[2]
40
+
41
+ if redis.call('EXISTS', payloadKey) == 1 then
42
+ redis.call('RENAME', payloadKey, claimedKey)
43
+ return redis.call('GET', claimedKey)
44
+ elseif redis.call('EXISTS', claimedKey) == 1 then
45
+ return redis.call('GET', claimedKey)
46
+ else
47
+ return nil
48
+ end
49
+ `;
50
+ /** Release a lock only if we still hold it (value matches our token). */
51
+ export const LUA_RELEASE_LOCK = `
52
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
53
+ return redis.call('DEL', KEYS[1])
54
+ end
55
+ return 0
56
+ `;
57
+
58
+ /** Extend a lock's TTL only if we still hold it. */
59
+ export const LUA_RENEW_LOCK = `
60
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
61
+ return redis.call('EXPIRE', KEYS[1], ARGV[2])
62
+ end
63
+ return 0
64
+ `;
@@ -0,0 +1,32 @@
1
+ import { LRUCache } from "@/shared/index.js";
2
+ import type { TemplateRepository } from "@/repositories/index.js";
3
+
4
+ export class TemplateCache {
5
+ private cache = new LRUCache<string, any>(1000, 5 * 60 * 1000);
6
+
7
+ constructor(private readonly templateRepo: TemplateRepository) {}
8
+
9
+ async getCachedTemplate(projectId: string, id: string) {
10
+ const key = `${projectId}:${id}`;
11
+ const cached = this.cache.get(key);
12
+ if (cached) return cached;
13
+
14
+ const dbTemplate = await this.templateRepo.findById(projectId, id);
15
+ if (dbTemplate) {
16
+ this.cache.set(key, dbTemplate);
17
+ }
18
+ return dbTemplate;
19
+ }
20
+
21
+ invalidate(projectId: string, id: string) {
22
+ this.cache.delete(`${projectId}:${id}`);
23
+ }
24
+
25
+ invalidateKey(key: string) {
26
+ this.cache.delete(key);
27
+ }
28
+
29
+ clear() {
30
+ this.cache.clear();
31
+ }
32
+ }
@@ -0,0 +1,69 @@
1
+ import type { RenderedContent } from "@/contracts/index.js";
2
+ export * from "./render.js";
3
+ export * from "./cache.js";
4
+
5
+ // ─── Types ──────────────────────────────────────────────────────────────────
6
+
7
+ export interface TemplateContext {
8
+ eventType: string;
9
+ templateVariables: Record<string, unknown>;
10
+ locale: string;
11
+ timezone: string;
12
+ deeplinkScheme: string;
13
+ }
14
+
15
+ export type TemplateRenderer = (ctx: TemplateContext) => RenderedContent;
16
+
17
+ /** Coerce a template variable to a non-empty string, or undefined. */
18
+ function asText(value: unknown): string | undefined {
19
+ return typeof value === "string" && value.length > 0 ? value : undefined;
20
+ }
21
+
22
+ // ─── TemplateRegistry ────────────────────────────────────────────────────────
23
+ //
24
+ // Open registry — new event types register a renderer without touching this file.
25
+ // The default renderer falls back to the i18n translation table, so most event
26
+ // types work without a custom renderer.
27
+
28
+ class TemplateRegistry {
29
+ private readonly renderers = new Map<string, TemplateRenderer>();
30
+
31
+ /**
32
+ * Register a custom renderer for an event type.
33
+ * Overwrites any previous registration for the same type.
34
+ */
35
+ register(eventType: string, renderer: TemplateRenderer): void {
36
+ this.renderers.set(eventType, renderer);
37
+ }
38
+
39
+ /** Render content for the given context, falling back to the i18n table. */
40
+ render(ctx: TemplateContext): RenderedContent {
41
+ const renderer = this.renderers.get(ctx.eventType);
42
+ if (renderer) return renderer(ctx);
43
+ return defaultRenderer(ctx);
44
+ }
45
+
46
+ has(eventType: string): boolean {
47
+ return this.renderers.has(eventType);
48
+ }
49
+
50
+ registeredTypes(): string[] {
51
+ return [...this.renderers.keys()];
52
+ }
53
+ }
54
+
55
+ function defaultRenderer(ctx: TemplateContext): RenderedContent {
56
+ const vars = ctx.templateVariables;
57
+
58
+ // Fall back to any caller-supplied title/body in the payload, then to a generic label.
59
+ const subject = asText(vars.subject ?? vars.title);
60
+ const body = asText(vars.body ?? vars.message) ?? `Notification: ${ctx.eventType}`;
61
+
62
+ return { content: { subject, body } };
63
+ }
64
+
65
+ export const templateRegistry = new TemplateRegistry();
66
+
67
+ export function renderTemplate(ctx: TemplateContext): RenderedContent {
68
+ return templateRegistry.render(ctx);
69
+ }
@@ -0,0 +1,128 @@
1
+ export function escapeHtml(unsafe: string): string {
2
+ return String(unsafe)
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;")
7
+ .replace(/'/g, "&#039;");
8
+ }
9
+
10
+ /** Strip CR/LF so an interpolated value cannot inject extra headers. */
11
+ export function escapeHeader(unsafe: string): string {
12
+ return String(unsafe)
13
+ .replace(/[\r\n]+/g, " ")
14
+ .trim();
15
+ }
16
+
17
+ export function interpolate(
18
+ tmpl: string,
19
+ variables: Record<string, unknown>,
20
+ sanitize = true,
21
+ ): string {
22
+ return tmpl
23
+ .replace(/\{\{\{(\w+)\}\}\}/g, (_, k: string) => {
24
+ return String(variables[k] ?? "");
25
+ })
26
+ .replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
27
+ const val = String(variables[k] ?? "");
28
+ return sanitize ? escapeHtml(val) : val;
29
+ });
30
+ }
31
+
32
+ /**
33
+ * How an interpolated value must be escaped, decided by the field it lands in.
34
+ *
35
+ * html — rendered as markup, so values are HTML-escaped
36
+ * header — single-line headers (subject, from, …), so CR/LF are stripped
37
+ * text — plain text body, no escaping needed
38
+ */
39
+ export type EscapeMode = "html" | "header" | "text";
40
+
41
+ const HTML_FIELDS = new Set(["html", "htmlbody", "bodyhtml", "htmlcontent"]);
42
+ const HEADER_FIELDS = new Set([
43
+ "subject",
44
+ "title",
45
+ "from",
46
+ "replyto",
47
+ "cc",
48
+ "bcc",
49
+ "preheader",
50
+ "preview",
51
+ ]);
52
+
53
+ function escapeModeFor(key: string, inherited: EscapeMode): EscapeMode {
54
+ const k = key.toLowerCase().replace(/[-_]/g, "");
55
+ if (HTML_FIELDS.has(k)) return "html";
56
+ if (HEADER_FIELDS.has(k)) return "header";
57
+ return inherited;
58
+ }
59
+
60
+ function applyEscape(value: string, mode: EscapeMode): string {
61
+ if (mode === "html") return escapeHtml(value);
62
+ if (mode === "header") return escapeHeader(value);
63
+ return value;
64
+ }
65
+
66
+ /**
67
+ * Interpolate `{{var}}` placeholders in a single leaf string.
68
+ *
69
+ * `{{{var}}}` (triple braces) interpolates raw unescaped values.
70
+ * `{{var}}` (double braces) applies contextual escaping to the substituted value.
71
+ */
72
+ function interpolateLeaf(
73
+ tmpl: string,
74
+ variables: Record<string, unknown>,
75
+ mode: EscapeMode,
76
+ ): string {
77
+ return tmpl
78
+ .replace(/\{\{\{(\w+)\}\}\}/g, (_, k: string) => {
79
+ const raw = variables[k];
80
+ if (raw === undefined || raw === null) return "";
81
+ return typeof raw === "string" ? raw : JSON.stringify(raw);
82
+ })
83
+ .replace(/\{\{(\w+)\}\}/g, (_, k: string) => {
84
+ const raw = variables[k];
85
+ if (raw === undefined || raw === null) return "";
86
+ return applyEscape(typeof raw === "string" ? raw : JSON.stringify(raw), mode);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Walk a template content tree and interpolate every leaf string in place.
92
+ *
93
+ * Values are substituted into the already-parsed structure. Interpolating into
94
+ * serialised JSON and re-parsing (the previous approach) let a value containing
95
+ * a quote either break JSON.parse outright or forge sibling fields such as
96
+ * `htmlBody`.
97
+ */
98
+ function renderNode(node: unknown, variables: Record<string, unknown>, mode: EscapeMode): unknown {
99
+ if (typeof node === "string") return interpolateLeaf(node, variables, mode);
100
+ if (Array.isArray(node)) return node.map((item) => renderNode(item, variables, mode));
101
+ if (node && typeof node === "object") {
102
+ const out: Record<string, unknown> = {};
103
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
104
+ out[key] = renderNode(value, variables, escapeModeFor(key, mode));
105
+ }
106
+ return out;
107
+ }
108
+ return node;
109
+ }
110
+
111
+ export function renderWithTemplate(
112
+ dbTemplate: { content?: any } | null | undefined,
113
+ templateVariables: Record<string, unknown>,
114
+ ): { content: Record<string, unknown> } {
115
+ const vars = templateVariables ?? {};
116
+
117
+ if (dbTemplate) {
118
+ const content = (dbTemplate.content ?? {}) as Record<string, unknown>;
119
+ return { content: renderNode(content, vars, "text") as Record<string, unknown> };
120
+ }
121
+
122
+ return {
123
+ content: {
124
+ subject: "Notification",
125
+ body: JSON.stringify(vars, null, 2),
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,96 @@
1
+ import type { NotificationChannel, NotificationDispatchedPayload } from "@/index.js";
2
+
3
+ // ─── Core interface ──────────────────────────────────────────────────────────
4
+ //
5
+ // Implement this interface to add a new channel (Email, SMS, APNS, Web Push …).
6
+ // Register the implementation with transportRegistry at app startup.
7
+
8
+ export interface DeliveryResult {
9
+ success: boolean;
10
+ providerMessageId?: string;
11
+ invalidToken?: boolean;
12
+ error?: string;
13
+ }
14
+
15
+ export interface WebhookEvent {
16
+ providerMessageId: string;
17
+ status: "opened" | "clicked" | "bounced" | "complained" | "unsubscribed";
18
+ timestamp?: Date;
19
+ /**
20
+ * The address the event concerns. Required to suppress it — without this a
21
+ * bounce or unsubscribe can be logged but not acted on, because the delivery
22
+ * log records the message, not the destination.
23
+ */
24
+ recipient?: string;
25
+ /**
26
+ * Whether a bounce is permanent. Only hard bounces suppress; a soft bounce is
27
+ * a full mailbox or a temporary outage and the address is still good. Left
28
+ * undefined by providers that do not distinguish them, which is treated as
29
+ * soft — the conservative reading, since wrongly suppressing a live address
30
+ * silently stops mail the person still wants.
31
+ */
32
+ bounceType?: "hard" | "soft";
33
+ /** Provider detail worth keeping: the clicked URL, the bounce description. */
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+
37
+ export interface Transport {
38
+ readonly channel: NotificationChannel;
39
+ readonly limits?: { limit: number; windowSeconds: number };
40
+ send(task: NotificationDispatchedPayload): Promise<DeliveryResult>;
41
+
42
+ webhookPath?: string;
43
+ /**
44
+ * Handles the one-time GET verification handshake some providers require
45
+ * before they'll start POSTing to a webhook URL (e.g. Meta's hub.challenge
46
+ * subscribe flow). Return the raw challenge string to echo back, or
47
+ * undefined to reject the request. Providers that don't require this
48
+ * (Resend, etc.) simply omit it.
49
+ */
50
+ verifyWebhookChallenge?: (
51
+ query: URLSearchParams,
52
+ ) => Promise<string | undefined> | string | undefined;
53
+ verifyWebhook?: (
54
+ rawBody: string,
55
+ headers: Record<string, string | string[] | undefined>,
56
+ ) => Promise<boolean> | boolean;
57
+ parseWebhook?: (
58
+ body: any,
59
+ rawBody?: string,
60
+ headers?: Record<string, string | string[] | undefined>,
61
+ ) => Promise<WebhookEvent[]>;
62
+ }
63
+
64
+ // ─── TransportRegistry ───────────────────────────────────────────────────────
65
+
66
+ class TransportRegistry {
67
+ private readonly transports = new Map<string, { transport: Transport; priority: number }[]>();
68
+
69
+ register(transport: Transport, priority: number = 0): void {
70
+ const list = this.transports.get(transport.channel) || [];
71
+ list.push({ transport, priority });
72
+ // Sort descending so higher priority is first
73
+ list.sort((a, b) => b.priority - a.priority);
74
+ this.transports.set(transport.channel, list);
75
+ }
76
+
77
+ get(channel: NotificationChannel): Transport | undefined {
78
+ const list = this.transports.get(channel);
79
+ return list && list.length > 0 ? list[0]?.transport : undefined;
80
+ }
81
+
82
+ getAll(channel: NotificationChannel): Transport[] {
83
+ const list = this.transports.get(channel);
84
+ return list ? list.map((item) => item.transport) : [];
85
+ }
86
+
87
+ registeredChannels(): string[] {
88
+ return [...this.transports.keys()];
89
+ }
90
+ }
91
+
92
+ export const transportRegistry = new TransportRegistry();
93
+
94
+ export function registerTransport(transport: Transport, priority: number = 0): void {
95
+ transportRegistry.register(transport, priority);
96
+ }