okengine 0.6.0 → 0.6.1

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 (38) hide show
  1. package/README.md +148 -13
  2. package/package.json +3 -3
  3. package/site/content/docs/elements/channel.mdx +71 -7
  4. package/site/content/docs/reference/configuration.mdx +5 -4
  5. package/site/content/docs/reference/environment-variables.mdx +32 -8
  6. package/src/cli/openbao-restart.integration.test.ts +106 -97
  7. package/src/docker/dockerfile.integration.test.ts +126 -119
  8. package/src/docker/stack.integration.test.ts +118 -102
  9. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  10. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  11. package/src/drivers/channel-fcm.ts +49 -53
  12. package/src/drivers/channel-msegat.ts +61 -0
  13. package/src/drivers/channel-sently-map.ts +57 -0
  14. package/src/drivers/channel-sently.test.ts +99 -0
  15. package/src/drivers/channel-sndr.ts +28 -0
  16. package/src/drivers/channel-taqnyat.ts +57 -0
  17. package/src/drivers/channel-types.ts +79 -2
  18. package/src/drivers/channel-unifonic.ts +26 -43
  19. package/src/drivers/channel-wa-cloud.ts +33 -47
  20. package/src/drivers/channel-webpush.ts +39 -239
  21. package/src/drivers/index.ts +4 -0
  22. package/src/elements/channel/costs.test.ts +2 -2
  23. package/src/elements/channel/costs.ts +14 -2
  24. package/src/elements/channel/mime.ts +11 -0
  25. package/src/elements/channel/runtime.ts +94 -0
  26. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  27. package/src/elements/channel.ts +10 -1
  28. package/src/elements/index.ts +9 -0
  29. package/src/kernel/boot-bind/channel.test.ts +68 -3
  30. package/src/kernel/boot-bind/channel.ts +93 -2
  31. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  32. package/src/release/exports.test.ts +26 -0
  33. package/src/release/exports.ts +64 -5
  34. package/src/release/index.ts +5 -0
  35. package/src/release/measure.exports.test.ts +13 -1
  36. package/src/release/measure.ts +76 -13
  37. package/src/release/official-plugins.ts +46 -0
  38. package/src/release/readme.test.ts +30 -2
@@ -1,7 +1,9 @@
1
1
  /**
2
- * `wa-cloud` channel driver — WhatsApp Cloud API (Meta).
2
+ * `wa-cloud` channel driver — WhatsApp Cloud API via sently.
3
3
  */
4
4
 
5
+ import { WhatsAppCloudTransport } from "sently/transports/whatsapp-cloud";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
5
7
  import type {
6
8
  ChannelDriver,
7
9
  ChannelMessage,
@@ -13,64 +15,48 @@ import type {
13
15
  /**
14
16
  * Open a WhatsApp Cloud API driver.
15
17
  *
16
- * @param options - `token` (access token) + `from` (phone number id)
18
+ * @param options - `token`/`apiKey` (access token) + `from` (phone number id)
17
19
  */
18
20
  export function openWaCloudChannel(options: ChannelOpenOptions = {}): ChannelDriver {
19
- const token = options.token ?? options.apiKey;
21
+ const accessToken = options.token ?? options.apiKey;
20
22
  const phoneNumberId = options.from;
21
- const fetchFn = options.fetch ?? globalThis.fetch;
22
- const base = options.url ?? "https://graph.facebook.com/v19.0";
23
+ if (!accessToken || !phoneNumberId) {
24
+ throw new Error("wa-cloud: token and from (phone number id) are required");
25
+ }
26
+
27
+ const transport = new WhatsAppCloudTransport({ accessToken, phoneNumberId });
23
28
 
24
29
  const channel: ChannelTransport = {
25
30
  provider: "wa-cloud",
26
31
  mediums: ["whatsapp"],
27
32
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
28
- if (!token || !phoneNumberId) {
29
- throw new Error("wa-cloud: token and from (phone number id) are required");
30
- }
31
- const res = await fetchFn(`${base}/${phoneNumberId}/messages`, {
32
- method: "POST",
33
- headers: {
34
- Authorization: `Bearer ${token}`,
35
- "Content-Type": "application/json",
36
- },
37
- body: JSON.stringify({
38
- messaging_product: "whatsapp",
39
- to: message.to,
40
- type: "text",
41
- text: { body: message.text ?? "" },
42
- }),
43
- });
44
- const body = (await res.json().catch(() => ({}))) as {
45
- messages?: Array<{ id?: string }>;
46
- error?: { message?: string };
47
- };
48
- const id = body.messages?.[0]?.id ?? crypto.randomUUID();
49
- if (!res.ok) {
50
- return {
51
- ok: false,
52
- messageId: id,
53
- driverId: "wa-cloud",
54
- attempts: [
55
- {
56
- driverId: "wa-cloud",
57
- ok: false,
58
- error: body.error?.message ?? `HTTP ${res.status}`,
59
- at: Date.now(),
60
- },
61
- ],
62
- };
33
+ try {
34
+ const templateName =
35
+ message.template ??
36
+ (typeof message.data?.template === "string" ? message.data.template : undefined);
37
+ const language =
38
+ message.locale ??
39
+ (typeof message.data?.language === "string" ? message.data.language : "en_US");
40
+
41
+ const result = templateName
42
+ ? await transport.send({
43
+ to: message.to,
44
+ template: { name: templateName, language },
45
+ })
46
+ : await transport.send({
47
+ to: message.to,
48
+ text: message.text ?? "",
49
+ });
50
+
51
+ return mapSentlySendResult("wa-cloud", result);
52
+ } catch (err) {
53
+ return mapSentlySendError("wa-cloud", err);
63
54
  }
64
- return {
65
- ok: true,
66
- messageId: id,
67
- driverId: "wa-cloud",
68
- attempts: [{ driverId: "wa-cloud", ok: true, at: Date.now(), messageId: id }],
69
- };
70
55
  },
56
+ verify: () => transport.verify(),
71
57
  };
72
58
 
73
- return { id: "wa-cloud", channel };
59
+ return { id: "wa-cloud", channel, whatsappTransport: transport };
74
60
  }
75
61
 
76
62
  /** WhatsApp Cloud driver factory. */
@@ -1,9 +1,12 @@
1
1
  /**
2
- * `webpush` channel driver — RFC 8030 (Web Push) + VAPID (RFC 8292).
2
+ * `webpush` channel driver — RFC 8030 + VAPID via sently.
3
3
  *
4
- * Implemented natively with Web Crypto. No third-party push library.
4
+ * Call path: ChannelTransport.send createPushSender WebPushTransport.
5
5
  */
6
6
 
7
+ import { createPushSender } from "sently/push";
8
+ import { WebPushTransport } from "sently/transports/webpush";
9
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
7
10
  import type {
8
11
  ChannelDriver,
9
12
  ChannelMessage,
@@ -12,263 +15,60 @@ import type {
12
15
  ChannelTransport,
13
16
  } from "./channel-types.ts";
14
17
 
15
- /**
16
- * Decode a URL-safe base64 string.
17
- *
18
- * @param input - Base64url text
19
- */
20
- function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
21
- const pad = "=".repeat((4 - (input.length % 4)) % 4);
22
- const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
23
- const bin = atob(b64);
24
- const out = new Uint8Array(bin.length);
25
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
26
- return out;
27
- }
28
-
29
- /** Fresh buffer for Web Crypto `BufferSource` typing. */
30
- function asBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
31
- return new Uint8Array(bytes);
32
- }
33
-
34
- /**
35
- * Encode bytes as base64url (no padding).
36
- *
37
- * @param bytes - Input
38
- */
39
- function b64urlEncode(bytes: Uint8Array): string {
40
- let s = "";
41
- for (const b of bytes) s += String.fromCharCode(b);
42
- return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
43
- }
44
-
45
- /**
46
- * Import a VAPID ECDSA P-256 private key from base64url raw or PKCS8.
47
- *
48
- * @param privateKeyB64 - Base64url private key
49
- */
50
- async function importVapidPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
51
- const raw = b64urlDecode(privateKeyB64);
52
- // Prefer PKCS8; fall back to raw JWK construction for 32-byte seeds.
53
- if (raw.length > 32) {
54
- return crypto.subtle.importKey(
55
- "pkcs8",
56
- asBufferSource(raw),
57
- { name: "ECDSA", namedCurve: "P-256" },
58
- false,
59
- ["sign"],
60
- );
61
- }
62
- // Build a minimal JWK from d (and a synthetic public x/y via derive is hard);
63
- // tests inject fetch and only need Authorization header shape — use ECDSA
64
- // with a generated key when raw seed is provided without full PKCS8.
65
- const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
66
- "sign",
67
- "verify",
68
- ]);
69
- return pair.privateKey;
70
- }
71
-
72
- /**
73
- * Build a VAPID JWT (RFC 8292) for the push audience.
74
- *
75
- * @param audience - Origin of the push service
76
- * @param subject - `mailto:` or HTTPS contact
77
- * @param privateKey - ECDSA P-256 key
78
- */
79
- async function buildVapidJwt(
80
- audience: string,
81
- subject: string,
82
- privateKey: CryptoKey,
83
- ): Promise<string> {
84
- const header = b64urlEncode(
85
- new TextEncoder().encode(JSON.stringify({ typ: "JWT", alg: "ES256" })),
86
- );
87
- const exp = Math.floor(Date.now() / 1000) + 12 * 60 * 60;
88
- const payload = b64urlEncode(
89
- new TextEncoder().encode(JSON.stringify({ aud: audience, exp, sub: subject })),
90
- );
91
- const data = new TextEncoder().encode(`${header}.${payload}`);
92
- const sig = new Uint8Array(
93
- await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, data),
94
- );
95
- // Convert DER → raw r||s if needed; Web Crypto in Bun returns raw P-1363.
96
- return `${header}.${payload}.${b64urlEncode(sig)}`;
97
- }
98
-
99
- /**
100
- * Minimal RFC 8030 encrypted body: salt + rs + server public + ciphertext.
101
- * For tests with injected fetch we send a well-formed envelope; production
102
- * should use full ece (aes128gcm). Here we implement aes128gcm content-coding
103
- * with Web Crypto ECDH + HKDF as specified.
104
- *
105
- * @param plaintext - Push payload bytes
106
- * @param p256dh - Subscriber public key (base64url)
107
- * @param auth - Subscriber auth secret (base64url)
108
- */
109
- async function encryptPushBody(
110
- plaintext: Uint8Array,
111
- p256dh: string,
112
- auth: string,
113
- ): Promise<{ body: Uint8Array; salt: Uint8Array; localPublic: Uint8Array }> {
114
- const userPublic = await crypto.subtle.importKey(
115
- "raw",
116
- asBufferSource(b64urlDecode(p256dh)),
117
- { name: "ECDH", namedCurve: "P-256" },
118
- true,
119
- [],
120
- );
121
- const local = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
122
- "deriveBits",
123
- ]);
124
- const shared = new Uint8Array(
125
- await crypto.subtle.deriveBits({ name: "ECDH", public: userPublic }, local.privateKey, 256),
126
- );
127
- const salt = crypto.getRandomValues(new Uint8Array(16));
128
- const authSecret = b64urlDecode(auth);
129
-
130
- // HKDF-SHA-256 as in RFC 8291
131
- const ikmKey = await crypto.subtle.importKey("raw", asBufferSource(shared), "HKDF", false, [
132
- "deriveBits",
133
- ]);
134
- const prk = new Uint8Array(
135
- await crypto.subtle.deriveBits(
136
- {
137
- name: "HKDF",
138
- hash: "SHA-256",
139
- salt: asBufferSource(authSecret),
140
- info: new TextEncoder().encode("WebPush: info\0"),
141
- },
142
- ikmKey,
143
- 256,
144
- ),
145
- );
146
- // Simplified content encryption key / nonce derivation for aes128gcm
147
- const prkKey = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits"]);
148
- const cek = new Uint8Array(
149
- await crypto.subtle.deriveBits(
150
- {
151
- name: "HKDF",
152
- hash: "SHA-256",
153
- salt,
154
- info: new TextEncoder().encode("Content-Encoding: aes128gcm\0"),
155
- },
156
- prkKey,
157
- 128,
158
- ),
159
- );
160
- const nonce = new Uint8Array(
161
- await crypto.subtle.deriveBits(
162
- {
163
- name: "HKDF",
164
- hash: "SHA-256",
165
- salt,
166
- info: new TextEncoder().encode("Content-Encoding: nonce\0"),
167
- },
168
- prkKey,
169
- 96,
170
- ),
171
- );
172
- const aes = await crypto.subtle.importKey("raw", cek, { name: "AES-GCM" }, false, ["encrypt"]);
173
- // Add RFC 8188 padding delimiter (0x02) then encrypt
174
- const padded = new Uint8Array(plaintext.length + 1);
175
- padded.set(plaintext, 0);
176
- padded[plaintext.length] = 2;
177
- const ciphertext = new Uint8Array(
178
- await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aes, padded),
179
- );
180
- const localPublic = new Uint8Array(await crypto.subtle.exportKey("raw", local.publicKey));
181
-
182
- // Body = salt (16) || rs (4) || idlen (1) || keyid || ciphertext
183
- const rs = new Uint8Array(4);
184
- new DataView(rs.buffer).setUint32(0, 4096);
185
- const idlen = new Uint8Array([localPublic.length]);
186
- const body = new Uint8Array(16 + 4 + 1 + localPublic.length + ciphertext.length);
187
- body.set(salt, 0);
188
- body.set(rs, 16);
189
- body.set(idlen, 20);
190
- body.set(localPublic, 21);
191
- body.set(ciphertext, 21 + localPublic.length);
192
- return { body, salt, localPublic };
193
- }
194
-
195
18
  /**
196
19
  * Open a Web Push driver (RFC 8030 + VAPID).
197
20
  *
198
21
  * @param options - VAPID keys + subject
199
22
  */
200
23
  export function openWebPushChannel(options: ChannelOpenOptions = {}): ChannelDriver {
201
- const vapidPublic = options.vapidPublicKey;
202
- const vapidPrivate = options.vapidPrivateKey;
24
+ const vapidPublicKey = options.vapidPublicKey;
25
+ const vapidPrivateKey = options.vapidPrivateKey;
203
26
  const subject = options.vapidSubject ?? "mailto:ops@oke.local";
204
- const fetchFn = options.fetch ?? globalThis.fetch;
27
+ if (!vapidPublicKey || !vapidPrivateKey) {
28
+ throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
29
+ }
30
+
31
+ const transport = new WebPushTransport({
32
+ vapidPublicKey,
33
+ vapidPrivateKey,
34
+ subject,
35
+ });
36
+ const sender = createPushSender({ transport });
205
37
 
206
38
  const channel: ChannelTransport = {
207
39
  provider: "webpush",
208
40
  mediums: ["push"],
209
41
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
210
- if (!vapidPrivate || !vapidPublic) {
211
- throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
212
- }
213
42
  const sub = message.pushSubscription;
214
43
  const endpoint = sub?.endpoint ?? message.to;
215
- if (!endpoint.startsWith("http")) {
216
- throw new Error("webpush: pushSubscription.endpoint (or https to) required");
217
- }
218
- const audience = new URL(endpoint).origin;
219
- const privateKey = await importVapidPrivateKey(vapidPrivate);
220
- const jwt = await buildVapidJwt(audience, subject, privateKey);
221
-
222
- const plaintext = new TextEncoder().encode(
223
- message.text ?? JSON.stringify(message.data ?? {}),
224
- );
225
- let body: Uint8Array = plaintext;
226
- const headers: Record<string, string> = {
227
- Authorization: `vapid t=${jwt}, k=${vapidPublic}`,
228
- TTL: "86400",
229
- Urgency: "normal",
230
- };
231
-
232
- if (sub?.keys?.p256dh && sub.keys.auth) {
233
- const enc = await encryptPushBody(plaintext, sub.keys.p256dh, sub.keys.auth);
234
- body = enc.body;
235
- headers["Content-Encoding"] = "aes128gcm";
236
- headers["Content-Type"] = "application/octet-stream";
237
- } else {
238
- headers["Content-Type"] = "text/plain;charset=utf-8";
44
+ if (!endpoint.startsWith("http") || !sub?.keys?.p256dh || !sub.keys.auth) {
45
+ throw new Error("webpush: pushSubscription with endpoint + keys is required");
239
46
  }
240
47
 
241
- const res = await fetchFn(endpoint, {
242
- method: "POST",
243
- headers,
244
- body: body as unknown as ArrayBuffer,
245
- });
246
- const id = res.headers.get("location") ?? crypto.randomUUID();
247
- if (!res.ok && res.status !== 201) {
248
- return {
249
- ok: false,
250
- messageId: id,
251
- driverId: "webpush",
252
- attempts: [
253
- {
254
- driverId: "webpush",
255
- ok: false,
256
- error: `HTTP ${res.status}`,
257
- at: Date.now(),
258
- },
259
- ],
260
- };
48
+ try {
49
+ const title =
50
+ message.subject ??
51
+ (typeof message.data?.title === "string" ? message.data.title : "Notification");
52
+ const body = message.text ?? JSON.stringify(message.data ?? {});
53
+ const result = await sender.send({
54
+ subscription: {
55
+ endpoint,
56
+ keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
57
+ },
58
+ title,
59
+ body,
60
+ ...(message.data ? { data: { ...message.data } } : {}),
61
+ });
62
+ return mapSentlySendResult("webpush", result);
63
+ } catch (err) {
64
+ return mapSentlySendError("webpush", err);
261
65
  }
262
- return {
263
- ok: true,
264
- messageId: id,
265
- driverId: "webpush",
266
- attempts: [{ driverId: "webpush", ok: true, at: Date.now(), messageId: id }],
267
- };
268
66
  },
67
+ verify: () => sender.verify(),
68
+ close: () => sender.close(),
269
69
  };
270
70
 
271
- return { id: "webpush", channel };
71
+ return { id: "webpush", channel, pushTransport: transport };
272
72
  }
273
73
 
274
74
  /** Web Push driver factory. */
@@ -139,10 +139,14 @@ export { createChannelInbox } from "./channel-types.ts";
139
139
  export { consoleChannelDriver, openConsoleChannel } from "./channel-console.ts";
140
140
  export { smtpChannelDriver, openSmtpChannel } from "./channel-smtp.ts";
141
141
  export { resendChannelDriver, openResendChannel } from "./channel-resend.ts";
142
+ export { sndrChannelDriver, openSndrChannel } from "./channel-sndr.ts";
143
+ export { taqnyatChannelDriver, openTaqnyatChannel } from "./channel-taqnyat.ts";
144
+ export { msegatChannelDriver, openMsegatChannel } from "./channel-msegat.ts";
142
145
  export { unifonicChannelDriver, openUnifonicChannel } from "./channel-unifonic.ts";
143
146
  export { waCloudChannelDriver, openWaCloudChannel } from "./channel-wa-cloud.ts";
144
147
  export { fcmChannelDriver, openFcmChannel } from "./channel-fcm.ts";
145
148
  export { webpushChannelDriver, openWebPushChannel } from "./channel-webpush.ts";
149
+ export { mapSentlySendResult, mapSentlySendError } from "./channel-sently-map.ts";
146
150
 
147
151
  export type {
148
152
  AiDriverId,
@@ -18,7 +18,7 @@ describe("fallbackWeeklyCostDelta", () => {
18
18
  status: "fallback",
19
19
  attempts: [
20
20
  { driverId: "wa-cloud", ok: false, at: weekStart + 1 },
21
- { driverId: "unifonic", ok: true, at: weekStart + 2 },
21
+ { driverId: "taqnyat", ok: true, at: weekStart + 2 },
22
22
  ],
23
23
  at: weekStart + 3,
24
24
  },
@@ -39,7 +39,7 @@ describe("fallbackWeeklyCostDelta", () => {
39
39
  status: "fallback",
40
40
  attempts: [
41
41
  { driverId: "wa-cloud", ok: false, at: weekStart + 6 },
42
- { driverId: "unifonic", ok: true, at: weekStart + 7 },
42
+ { driverId: "msegat", ok: true, at: weekStart + 7 },
43
43
  ],
44
44
  at: weekStart + 8,
45
45
  },
@@ -106,13 +106,25 @@ export function fallbackWeeklyCostDelta(
106
106
  function mediumFromDriver(driverId: string, receiptMedium: string): string {
107
107
  const id = driverId.toLowerCase();
108
108
  if (id.includes("wa") || id.includes("whatsapp")) return "whatsapp";
109
- if (id.includes("sms") || id.includes("unifonic") || id.includes("twilio")) {
109
+ if (
110
+ id.includes("sms") ||
111
+ id.includes("taqnyat") ||
112
+ id.includes("msegat") ||
113
+ id.includes("unifonic") ||
114
+ id.includes("twilio")
115
+ ) {
110
116
  return "sms";
111
117
  }
112
118
  if (id.includes("push") || id.includes("fcm") || id.includes("webpush")) {
113
119
  return "push";
114
120
  }
115
- if (id.includes("smtp") || id.includes("resend") || id.includes("ses") || id.includes("email")) {
121
+ if (
122
+ id.includes("smtp") ||
123
+ id.includes("resend") ||
124
+ id.includes("sndr") ||
125
+ id.includes("ses") ||
126
+ id.includes("email")
127
+ ) {
116
128
  return "email";
117
129
  }
118
130
  return receiptMedium === "any" ? "email" : receiptMedium;
@@ -11,3 +11,14 @@ export type { Attachment, MailOptions, SendResult, Transport, RetryConfig } from
11
11
  export { SentlyError } from "sently/errors";
12
12
  export { RetryTransport } from "sently/transports/retry";
13
13
  export { FallbackTransport, FallbackError, type FallbackAttempt } from "sently/transports/fallback";
14
+ export {
15
+ toChannelSendResult,
16
+ type AnySendResult,
17
+ type ChannelSendResult as SentlyChannelSendResult,
18
+ } from "sently/channel-result";
19
+ export {
20
+ parse as parseSndrWebhook,
21
+ verifySignature as verifySndrSignature,
22
+ } from "sently/webhooks/sndr";
23
+ export { parse as parseUnifonicWebhook } from "sently/webhooks/unifonic";
24
+ export { toDeliveryEvent, type EmailEvent, type DeliveryEvent } from "sently/webhooks";
@@ -246,10 +246,104 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
246
246
  }
247
247
  }
248
248
 
249
+ async function sendViaSmsFallback(
250
+ chain: ChannelDriver[],
251
+ message: ChannelMessage,
252
+ ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] } | undefined> {
253
+ const sms = chain
254
+ .map((d) => (d.smsTransport ? { driver: d, transport: d.smsTransport } : undefined))
255
+ .filter(
256
+ (
257
+ x,
258
+ ): x is { driver: ChannelDriver; transport: NonNullable<ChannelDriver["smsTransport"]> } =>
259
+ !!x,
260
+ );
261
+ if (sms.length === 0) return undefined;
262
+
263
+ const attempts: ChannelAttempt[] = [];
264
+ const transports = sms.map(({ transport }) =>
265
+ options.retry ? new RetryTransport(transport) : transport,
266
+ );
267
+ const fallback = new FallbackTransport(transports, {
268
+ onFallback(failedIndex, error) {
269
+ const provider =
270
+ transports[failedIndex]?.provider ?? sms[failedIndex]?.driver.id ?? `sms-${failedIndex}`;
271
+ attempts.push({
272
+ driverId: provider,
273
+ ok: false,
274
+ error: error instanceof Error ? error.message : String(error),
275
+ at: now(),
276
+ });
277
+ },
278
+ });
279
+
280
+ const body = {
281
+ to: message.to,
282
+ body: message.text ?? String(message.data?.code ?? ""),
283
+ ...(message.from ? { from: message.from } : {}),
284
+ };
285
+
286
+ try {
287
+ const sendResult = await fallback.send(body);
288
+ const driverId =
289
+ sendResult.provider ??
290
+ transports[sendResult.providerIndex ?? 0]?.provider ??
291
+ sms[0]?.driver.id ??
292
+ "sms";
293
+ attempts.push({
294
+ driverId,
295
+ ok: true,
296
+ at: now(),
297
+ messageId: sendResult.messageId,
298
+ });
299
+ return {
300
+ result: {
301
+ ok: true,
302
+ messageId: sendResult.messageId,
303
+ driverId,
304
+ attempts,
305
+ },
306
+ attempts,
307
+ };
308
+ } catch (err) {
309
+ const fbAttempts =
310
+ err &&
311
+ typeof err === "object" &&
312
+ "attempts" in err &&
313
+ Array.isArray((err as { attempts: FallbackAttempt[] }).attempts)
314
+ ? (err as { attempts: FallbackAttempt[] }).attempts
315
+ : [];
316
+ for (const a of fbAttempts) {
317
+ if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) {
318
+ attempts.push({
319
+ driverId: a.provider,
320
+ ok: false,
321
+ error: a.error instanceof Error ? a.error.message : String(a.error),
322
+ at: now(),
323
+ });
324
+ }
325
+ }
326
+ return {
327
+ result: {
328
+ ok: false,
329
+ messageId: crypto.randomUUID(),
330
+ driverId: "fallback",
331
+ attempts,
332
+ },
333
+ attempts,
334
+ };
335
+ }
336
+ }
337
+
249
338
  async function sendViaChannelChain(
250
339
  chain: ChannelDriver[],
251
340
  message: ChannelMessage,
252
341
  ): Promise<{ result: ChannelSendResult; attempts: ChannelAttempt[] }> {
342
+ if (message.medium === "sms") {
343
+ const viaSms = await sendViaSmsFallback(chain, message);
344
+ if (viaSms) return viaSms;
345
+ }
346
+
253
347
  const attempts: ChannelAttempt[] = [];
254
348
  for (const d of chain) {
255
349
  if (!d.channel) continue;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SNDR webhook helpers re-exported from sently.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { parseSndrWebhook } from "./mime.ts";
7
+
8
+ describe("parseSndrWebhook", () => {
9
+ test("normalizes a delivery event payload", () => {
10
+ const events = parseSndrWebhook({
11
+ type: "email.delivered",
12
+ data: {
13
+ email_id: "em_test",
14
+ to: ["user@example.com"],
15
+ },
16
+ });
17
+ expect(events).toEqual([
18
+ expect.objectContaining({
19
+ provider: "sndr",
20
+ type: "delivered",
21
+ messageId: "em_test",
22
+ recipient: "user@example.com",
23
+ }),
24
+ ]);
25
+ });
26
+ });
@@ -2,7 +2,7 @@
2
2
  * Channel element — reaching humans.
3
3
  *
4
4
  * Physics: email · SMS · WhatsApp · push.
5
- * Drivers: `console` · `smtp` · `resend` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
5
+ * Drivers: `console` · `smtp` · `resend` · `sndr` · `taqnyat` · `msegat` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
6
6
  *
7
7
  * Transport interface is identical to sently's so its transports run unchanged.
8
8
  * MIME, attachments, retry, and the unified error hierarchy come from sently.
@@ -91,9 +91,18 @@ export {
91
91
  RetryTransport,
92
92
  FallbackTransport,
93
93
  FallbackError,
94
+ toChannelSendResult,
95
+ toDeliveryEvent,
96
+ parseSndrWebhook,
97
+ verifySndrSignature,
98
+ parseUnifonicWebhook,
94
99
  type Attachment,
95
100
  type MailOptions,
96
101
  type SendResult,
97
102
  type Transport,
98
103
  type FallbackAttempt,
104
+ type EmailEvent,
105
+ type DeliveryEvent,
106
+ type AnySendResult,
107
+ type SentlyChannelSendResult,
99
108
  } from "./channel/mime.ts";
@@ -132,6 +132,11 @@ export {
132
132
  RetryTransport,
133
133
  FallbackTransport,
134
134
  FallbackError,
135
+ parseSndrWebhook,
136
+ verifySndrSignature,
137
+ parseUnifonicWebhook,
138
+ toChannelSendResult,
139
+ toDeliveryEvent,
135
140
  } from "./channel.ts";
136
141
  export type {
137
142
  ChannelTemplateDecl,
@@ -150,6 +155,10 @@ export type {
150
155
  Transport,
151
156
  MediumCosts,
152
157
  EmailAuthResult,
158
+ EmailEvent,
159
+ DeliveryEvent,
160
+ AnySendResult,
161
+ SentlyChannelSendResult,
153
162
  } from "./channel.ts";
154
163
 
155
164
  export {