sently 1.0.1 → 1.2.0

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 (49) hide show
  1. package/AGENTS.md +3 -2
  2. package/CHANGELOG.md +110 -1
  3. package/README.md +20 -5
  4. package/dist/chunk-z1589fjk.js.map +2 -2
  5. package/dist/core/push-types.d.ts +53 -4
  6. package/dist/transports/hostinger.d.ts +128 -0
  7. package/dist/transports/hostinger.js +3 -0
  8. package/dist/transports/hostinger.js.map +10 -0
  9. package/dist/transports/inbucket.d.ts +196 -0
  10. package/dist/transports/inbucket.js +3 -0
  11. package/dist/transports/inbucket.js.map +10 -0
  12. package/dist/transports/mailpit.d.ts +108 -8
  13. package/dist/transports/mailpit.js +2 -2
  14. package/dist/transports/mailpit.js.map +3 -3
  15. package/dist/transports/taqnyat-sms.d.ts +85 -0
  16. package/dist/transports/taqnyat-sms.js +2 -2
  17. package/dist/transports/taqnyat-sms.js.map +3 -3
  18. package/dist/transports/taqnyat-whatsapp.d.ts +112 -4
  19. package/dist/transports/taqnyat-whatsapp.js +2 -2
  20. package/dist/transports/taqnyat-whatsapp.js.map +3 -3
  21. package/dist/transports/webpush.d.ts +19 -0
  22. package/dist/transports/webpush.js +2 -2
  23. package/dist/transports/webpush.js.map +3 -3
  24. package/dist/webhooks/sndr.js +2 -2
  25. package/dist/webhooks/sndr.js.map +3 -3
  26. package/package.json +12 -2
  27. package/site/content/docs/ai/llms-txt.mdx +2 -0
  28. package/site/content/docs/channels/email.mdx +2 -1
  29. package/site/content/docs/channels/push.mdx +3 -1
  30. package/site/content/docs/decorators/preview.mdx +2 -1
  31. package/site/content/docs/get-started/entrypoints.mdx +1 -1
  32. package/site/content/docs/get-started/support-matrix.mdx +2 -2
  33. package/site/content/docs/guides/vendor-extras-otp.mdx +2 -1
  34. package/site/content/docs/guides/webhooks.mdx +2 -0
  35. package/site/content/docs/guides/webpush-interop.mdx +9 -1
  36. package/site/content/docs/reference/exports.mdx +1 -1
  37. package/site/content/docs/reference/push-options.mdx +22 -5
  38. package/site/content/docs/transports/hostinger.mdx +435 -0
  39. package/site/content/docs/transports/inbucket.mdx +200 -0
  40. package/site/content/docs/transports/index.mdx +3 -3
  41. package/site/content/docs/transports/mailpit.mdx +114 -14
  42. package/site/content/docs/transports/meta.json +6 -4
  43. package/site/content/docs/transports/smtp.mdx +3 -2
  44. package/site/content/docs/transports/sndr.mdx +68 -5
  45. package/site/content/docs/transports/taqnyat.mdx +365 -0
  46. package/site/content/docs/transports/webpush.mdx +201 -17
  47. package/site/content/docs/transports/taqnyat-mail.mdx +0 -41
  48. package/site/content/docs/transports/taqnyat-sms.mdx +0 -41
  49. package/site/content/docs/transports/taqnyat-whatsapp.mdx +0 -40
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/transports/webpush.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * @module\n * Web Push transport (RFC 8292 VAPID + RFC 8291 aes128gcm).\n *\n * VAPID keys are the common web-push raw format: base64url-encoded\n * uncompressed P-256 public key (65 bytes) and raw private key (32 bytes).\n *\n * Store `vapidPrivateKey` in a secrets manager / environment variable — never\n * hardcode it or commit it to source control.\n *\n * @example\n * ```ts\n * import { createPushSender } from \"sently/push\";\n * import { WebPushTransport } from \"sently/transports/webpush\";\n *\n * const push = createPushSender({\n * transport: new WebPushTransport({\n * vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,\n * vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,\n * subject: \"mailto:you@example.com\",\n * }),\n * });\n * ```\n */\nimport { isValidEmail } from \"../core/address.js\";\nimport { decodeBase64Url, encodeBase64Url, encodeUtf8 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport { assertSafePushEndpoint } from \"../core/push-endpoint.js\";\nimport type {\n PushOptions,\n PushSendResult,\n PushSubscription,\n PushTransport,\n} from \"../core/push-types.js\";\nimport { isWebPushOptions } from \"../core/push-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\n\n/** Web Push / VAPID configuration. */\nexport interface WebPushConfig {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n vapidPublicKey: string;\n /**\n * Base64url-encoded raw P-256 private key (32 bytes).\n * Treat as a tier-1 secret — inject from env / secrets manager only.\n */\n vapidPrivateKey: string;\n /**\n * Contact URI for the VAPID `sub` claim. Must be a `mailto:` address\n * (e.g. `mailto:you@example.com`) or an `https:` URL\n * (e.g. `https://example.com/contact`). Validated at construction.\n */\n subject: string;\n /**\n * Extra exact hostnames allowed for `subscription.endpoint` beyond the\n * built-in FCM / Mozilla / Apple / WNS allowlist. Use for private push relays.\n */\n allowedEndpointHosts?: string[];\n}\n\n/** Error thrown when a push service rejects the request. */\nexport class WebPushError extends SentlyError {\n /** Creates a Web Push error with HTTP status and response body. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"webpush\",\n cause: apiError,\n });\n this.name = \"WebPushError\";\n }\n}\n\n/** Default TTL (28 days) in seconds. */\nconst DEFAULT_TTL_SECONDS = 2419200;\n/** RFC 8188 / Web Push record size. */\nconst RECORD_SIZE = 4096;\n/**\n * Maximum encrypted body size push services are required to accept (RFC 8291).\n * Includes salt, rs, keyid, and ciphertext+tag.\n */\nconst MAX_ENCRYPTED_BODY_BYTES = 4096;\n\n/**\n * RFC 8292 VAPID `sub` must identify the sender as `mailto:` or `https:`.\n * Checked at construction so a bare handle like `@oke.local` fails here,\n * not later as an opaque 403 from a push service.\n */\nfunction assertVapidSubject(subject: string): void {\n if (subject.startsWith(\"mailto:\")) {\n const email = subject.slice(\"mailto:\".length);\n if (isValidEmail(email)) {\n return;\n }\n } else if (subject.startsWith(\"https:\")) {\n try {\n const url = new URL(subject);\n if (url.protocol === \"https:\" && url.hostname.length > 0) {\n return;\n }\n } catch {\n // fall through to the shared error\n }\n }\n\n throw new WebPushError(\n `WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(subject)}`,\n 400,\n { field: \"subject\", value: subject },\n );\n}\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;\n}\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction uint32Be(value: number): Uint8Array {\n const out = new Uint8Array(4);\n new DataView(out.buffer).setUint32(0, value, false);\n return out;\n}\n\nfunction ecPublicToJwk(publicKey: Uint8Array): JsonWebKey {\n if (publicKey.length !== 65 || publicKey[0] !== 0x04) {\n throw new WebPushError(\"Invalid P-256 uncompressed public key\", 400, {\n length: publicKey.length,\n });\n }\n return {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.subarray(1, 33)),\n y: encodeBase64Url(publicKey.subarray(33, 65)),\n };\n}\n\nfunction ecKeyPairToJwk(publicKey: Uint8Array, privateKey: Uint8Array): JsonWebKey {\n return {\n ...ecPublicToJwk(publicKey),\n d: encodeBase64Url(privateKey),\n };\n}\n\nasync function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", cryptoKey, toArrayBuffer(data));\n return new Uint8Array(sig);\n}\n\n/** HKDF-Extract + Expand (HMAC-SHA-256) for a single block (L ≤ 32). */\nasync function hkdf(\n salt: Uint8Array,\n ikm: Uint8Array,\n info: Uint8Array,\n length: number,\n): Promise<Uint8Array> {\n const prk = await hmacSha256(salt, ikm);\n const block = await hmacSha256(prk, concatBytes(info, new Uint8Array([0x01])));\n return block.subarray(0, length);\n}\n\n/**\n * Encrypt a payload per RFC 8291 (aes128gcm content coding).\n *\n * Confidence note: Implemented from RFC 8291 / RFC 8188. Tests assert header\n * shapes, framing, and size limits; full known-vector fixtures were not\n * available in-repo — treat as high-scrutiny crypto.\n */\nasync function encryptAes128Gcm(\n subscription: PushSubscription,\n plaintext: Uint8Array,\n): Promise<Uint8Array> {\n const uaPublic = decodeBase64Url(subscription.keys.p256dh);\n const authSecret = decodeBase64Url(subscription.keys.auth);\n\n if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {\n throw new WebPushError(\"Invalid subscription p256dh key\", 400, {\n length: uaPublic.length,\n });\n }\n if (authSecret.length < 16) {\n throw new WebPushError(\"Invalid subscription auth key\", 400, {\n length: authSecret.length,\n });\n }\n\n const asKeyPair = await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\n \"deriveBits\",\n ]);\n const asPublicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", asKeyPair.publicKey));\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(uaPublic),\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const ecdhSecret = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, asKeyPair.privateKey, 256),\n );\n\n const keyInfo = concatBytes(encodeUtf8(\"WebPush: info\\0\"), uaPublic, asPublicRaw);\n const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32);\n\n const salt = crypto.getRandomValues(new Uint8Array(16));\n const cekInfo = encodeUtf8(\"Content-Encoding: aes128gcm\\0\");\n const nonceInfo = encodeUtf8(\"Content-Encoding: nonce\\0\");\n const cek = await hkdf(salt, ikm, cekInfo, 16);\n const nonce = await hkdf(salt, ikm, nonceInfo, 12);\n\n // RFC 8188 final-record padding delimiter\n const padded = concatBytes(plaintext, new Uint8Array([0x02]));\n\n const aesKey = await crypto.subtle.importKey(\"raw\", toArrayBuffer(cek), \"AES-GCM\", false, [\n \"encrypt\",\n ]);\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv: toArrayBuffer(nonce) },\n aesKey,\n toArrayBuffer(padded),\n ),\n );\n\n // salt || rs || idlen || keyid || ciphertext\n return concatBytes(\n salt,\n uint32Be(RECORD_SIZE),\n new Uint8Array([asPublicRaw.length]),\n asPublicRaw,\n ciphertext,\n );\n}\n\nasync function signVapidJwt(\n endpoint: string,\n subject: string,\n vapidPublicKey: string,\n vapidPrivateKey: string,\n): Promise<string> {\n const publicKey = decodeBase64Url(vapidPublicKey);\n const privateKey = decodeBase64Url(vapidPrivateKey);\n const jwk = ecKeyPairToJwk(publicKey, privateKey);\n\n const key = await crypto.subtle.importKey(\n \"jwk\",\n jwk,\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n\n const origin = new URL(endpoint).origin;\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const payload = {\n aud: origin,\n exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,\n sub: subject,\n };\n\n const encodedHeader = encodeBase64Url(encodeUtf8(JSON.stringify(header)));\n const encodedPayload = encodeBase64Url(encodeUtf8(JSON.stringify(payload)));\n const signingInput = `${encodedHeader}.${encodedPayload}`;\n\n const signature = new Uint8Array(\n await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n key,\n toArrayBuffer(encodeUtf8(signingInput)),\n ),\n );\n\n return `${signingInput}.${encodeBase64Url(signature)}`;\n}\n\n/**\n * Web Push transport — VAPID auth + RFC 8291 payload encryption.\n *\n * Endpoint URLs are validated against an allowlist before fetch to mitigate SSRF.\n * Redirects are not followed.\n */\nexport class WebPushTransport implements PushTransport {\n readonly provider = \"webpush\";\n\n private readonly vapidPublicKey: string;\n private readonly vapidPrivateKey: string;\n private readonly subject: string;\n private readonly allowedEndpointHosts: readonly string[];\n\n /** Creates a Web Push transport with VAPID credentials. */\n constructor(config: WebPushConfig) {\n assertVapidSubject(config.subject);\n this.vapidPublicKey = config.vapidPublicKey;\n this.vapidPrivateKey = config.vapidPrivateKey;\n this.subject = config.subject;\n this.allowedEndpointHosts = config.allowedEndpointHosts ?? [];\n }\n\n /** Encrypts and POSTs a notification to the subscription endpoint. */\n async send(options: PushOptions): Promise<PushSendResult> {\n if (!isWebPushOptions(options)) {\n throw new WebPushError(\n \"WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens\",\n 400,\n { hint: \"fcm\" },\n );\n }\n\n try {\n assertSafePushEndpoint(options.subscription.endpoint, this.allowedEndpointHosts);\n } catch (error) {\n let endpointHost: string | undefined;\n try {\n endpointHost = new URL(options.subscription.endpoint).hostname;\n } catch {\n endpointHost = undefined;\n }\n throw new WebPushError(\n error instanceof Error ? error.message : \"Unsafe push subscription endpoint\",\n 400,\n { endpointHost },\n );\n }\n\n const notification = {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n };\n const plaintext = encodeUtf8(JSON.stringify(notification));\n const encrypted = await encryptAes128Gcm(options.subscription, plaintext);\n\n if (encrypted.length > MAX_ENCRYPTED_BODY_BYTES) {\n throw new WebPushError(\n `Encrypted push payload exceeds ${MAX_ENCRYPTED_BODY_BYTES} bytes (RFC 8291)`,\n 413,\n { size: encrypted.length },\n );\n }\n\n const jwt = await signVapidJwt(\n options.subscription.endpoint,\n this.subject,\n this.vapidPublicKey,\n this.vapidPrivateKey,\n );\n\n const ttl = options.ttl ?? DEFAULT_TTL_SECONDS;\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers: {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n },\n body: toArrayBuffer(encrypted),\n });\n\n // Opaque redirect responses (3xx) must not be followed — SSRF mitigation.\n if (response.status >= 300 && response.status < 400) {\n throw new WebPushError(\n `Push service returned redirect ${response.status}; redirects are not followed`,\n response.status,\n { location: response.headers.get(\"location\") },\n );\n }\n\n if (!response.ok) {\n const bodyText = await response.text().catch(() => \"\");\n throw new WebPushError(\n bodyText || `Web Push request failed with status ${response.status}`,\n response.status,\n bodyText || { status: response.status },\n );\n }\n\n return {\n messageId: options.messageId ?? crypto.randomUUID(),\n status: \"accepted\",\n response: String(response.status),\n provider: \"webpush\",\n };\n }\n\n /** Lightweight VAPID credential shape check. */\n async verify(): Promise<VerifyResult> {\n try {\n const pub = decodeBase64Url(this.vapidPublicKey);\n const priv = decodeBase64Url(this.vapidPrivateKey);\n const ok = pub.length === 65 && priv.length === 32 && this.subject.length > 0;\n return {\n ok,\n provider: \"webpush\",\n message: ok ? \"VAPID credentials present\" : \"Invalid VAPID key lengths\",\n };\n } catch (error) {\n return {\n ok: false,\n provider: \"webpush\",\n message: error instanceof Error ? error.message : \"Invalid VAPID credentials\",\n };\n }\n }\n}\n"
5
+ "/**\n * @module\n * Web Push transport (RFC 8292 VAPID + RFC 8291 aes128gcm).\n *\n * VAPID keys are the common web-push raw format: base64url-encoded\n * uncompressed P-256 public key (65 bytes) and raw private key (32 bytes).\n *\n * Store `vapidPrivateKey` in a secrets manager / environment variable — never\n * hardcode it or commit it to source control.\n *\n * @example\n * ```ts\n * import { createPushSender } from \"sently/push\";\n * import { WebPushTransport } from \"sently/transports/webpush\";\n *\n * const push = createPushSender({\n * transport: new WebPushTransport({\n * vapidPublicKey: process.env.VAPID_PUBLIC_KEY!,\n * vapidPrivateKey: process.env.VAPID_PRIVATE_KEY!,\n * subject: \"mailto:you@example.com\",\n * }),\n * });\n * ```\n */\nimport { isValidEmail } from \"../core/address.js\";\nimport { decodeBase64Url, encodeBase64Url, encodeUtf8 } from \"../core/base64.js\";\nimport { httpStatusToSentlyCode, SentlyError } from \"../core/errors.js\";\nimport { assertSafePushEndpoint } from \"../core/push-endpoint.js\";\nimport type {\n PushOptions,\n PushSendResult,\n PushSubscription,\n PushTransport,\n WebPushOptions,\n WebPushUrgency,\n} from \"../core/push-types.js\";\nimport { isWebPushOptions } from \"../core/push-types.js\";\nimport type { VerifyResult } from \"../core/types.js\";\n\n/** Web Push / VAPID configuration. */\nexport interface WebPushConfig {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n vapidPublicKey: string;\n /**\n * Base64url-encoded raw P-256 private key (32 bytes).\n * Treat as a tier-1 secret — inject from env / secrets manager only.\n */\n vapidPrivateKey: string;\n /**\n * Contact URI for the VAPID `sub` claim. Must be a `mailto:` address\n * (e.g. `mailto:you@example.com`) or an `https:` URL\n * (e.g. `https://example.com/contact`). Validated at construction.\n */\n subject: string;\n /**\n * Extra exact hostnames allowed for `subscription.endpoint` beyond the\n * built-in FCM / Mozilla / Apple / WNS allowlist. Use for private push relays.\n */\n allowedEndpointHosts?: string[];\n}\n\n/** Generated VAPID key pair in the common web-push raw format. */\nexport interface VapidKeys {\n /** Base64url-encoded uncompressed P-256 public key (65 bytes). */\n publicKey: string;\n /** Base64url-encoded raw P-256 private key (32 bytes) — treat as a secret. */\n privateKey: string;\n}\n\nconst WEB_PUSH_URGENCIES: ReadonlySet<WebPushUrgency> = new Set([\n \"very-low\",\n \"low\",\n \"normal\",\n \"high\",\n]);\n\n/** RFC 8030 Topic: printable ASCII, max 32 characters. */\nconst TOPIC_PATTERN = /^[\\x21-\\x7E]{1,32}$/;\n\n/** Error thrown when a push service rejects the request. */\nexport class WebPushError extends SentlyError {\n /** Creates a Web Push error with HTTP status and response body. */\n constructor(\n message: string,\n public readonly statusCode: number,\n public readonly apiError: unknown,\n ) {\n super(message, httpStatusToSentlyCode(statusCode), {\n statusCode,\n provider: \"webpush\",\n cause: apiError,\n });\n this.name = \"WebPushError\";\n }\n}\n\n/** Default TTL (28 days) in seconds. */\nconst DEFAULT_TTL_SECONDS = 2419200;\n/** RFC 8188 / Web Push record size. */\nconst RECORD_SIZE = 4096;\n/**\n * Maximum encrypted body size push services are required to accept (RFC 8291).\n * Includes salt, rs, keyid, and ciphertext+tag.\n */\nconst MAX_ENCRYPTED_BODY_BYTES = 4096;\n\n/**\n * RFC 8292 VAPID `sub` must identify the sender as `mailto:` or `https:`.\n * Checked at construction so a bare handle like `@oke.local` fails here,\n * not later as an opaque 403 from a push service.\n */\nfunction assertVapidSubject(subject: string): void {\n if (subject.startsWith(\"mailto:\")) {\n const email = subject.slice(\"mailto:\".length);\n if (isValidEmail(email)) {\n return;\n }\n } else if (subject.startsWith(\"https:\")) {\n try {\n const url = new URL(subject);\n if (url.protocol === \"https:\" && url.hostname.length > 0) {\n return;\n }\n } catch {\n // fall through to the shared error\n }\n }\n\n throw new WebPushError(\n `WebPushConfig.subject must be a mailto: address or https: URL identifying you to push services, got: ${JSON.stringify(subject)}`,\n 400,\n { field: \"subject\", value: subject },\n );\n}\n\n/**\n * Generate a VAPID key pair in the common web-push raw format\n * (base64url public + base64url private `d`).\n *\n * @example\n * ```ts\n * import { generateVapidKeys } from \"sently/transports/webpush\";\n *\n * const { publicKey, privateKey } = await generateVapidKeys();\n * ```\n */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const keyPair = await crypto.subtle.generateKey({ name: \"ECDSA\", namedCurve: \"P-256\" }, true, [\n \"sign\",\n \"verify\",\n ]);\n const publicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", keyPair.publicKey));\n const jwk = (await crypto.subtle.exportKey(\"jwk\", keyPair.privateKey)) as JsonWebKey;\n if (typeof jwk.d !== \"string\" || jwk.d.length === 0) {\n throw new WebPushError(\"Failed to export VAPID private key\", 500, { field: \"privateKey\" });\n }\n return {\n publicKey: encodeBase64Url(publicRaw),\n privateKey: jwk.d,\n };\n}\n\nfunction assertUrgency(urgency: string): asserts urgency is WebPushUrgency {\n if (!WEB_PUSH_URGENCIES.has(urgency as WebPushUrgency)) {\n throw new WebPushError(\n `Web Push urgency must be very-low, low, normal, or high, got: ${JSON.stringify(urgency)}`,\n 400,\n { field: \"urgency\", value: urgency },\n );\n }\n}\n\nfunction assertTopic(topic: string): void {\n if (!TOPIC_PATTERN.test(topic)) {\n throw new WebPushError(\n `Web Push topic must be 1–32 printable ASCII characters, got: ${JSON.stringify(topic)}`,\n 400,\n { field: \"topic\", value: topic },\n );\n }\n}\n\n/**\n * Build the JSON object encrypted for the service worker.\n * Visible notifications need `title` + `body`; silent / data-only send `data` alone.\n */\nfunction buildWebPushPayload(options: WebPushOptions): Record<string, unknown> {\n if (options.silent) {\n if (options.data === undefined) {\n throw new WebPushError(\"silent Web Push requires data\", 400, { field: \"data\" });\n }\n return { data: options.data };\n }\n\n const hasTitle = typeof options.title === \"string\" && options.title.length > 0;\n const hasBody = typeof options.body === \"string\" && options.body.length > 0;\n\n if (!hasTitle && !hasBody) {\n if (options.data === undefined) {\n throw new WebPushError(\n \"Web Push requires title and body, or data for a data-only / silent send\",\n 400,\n { fields: [\"title\", \"body\", \"data\"] },\n );\n }\n return { data: options.data };\n }\n\n if (!hasTitle || !hasBody) {\n throw new WebPushError(\"Visible Web Push notifications require both title and body\", 400, {\n fields: [\"title\", \"body\"],\n });\n }\n\n return {\n title: options.title,\n body: options.body,\n ...(options.data !== undefined ? { data: options.data } : {}),\n ...(options.icon !== undefined ? { icon: options.icon } : {}),\n ...(options.badge !== undefined ? { badge: options.badge } : {}),\n ...(options.image !== undefined ? { image: options.image } : {}),\n ...(options.tag !== undefined ? { tag: options.tag } : {}),\n ...(options.actions !== undefined ? { actions: options.actions } : {}),\n ...(options.requireInteraction !== undefined\n ? { requireInteraction: options.requireInteraction }\n : {}),\n ...(options.renotify !== undefined ? { renotify: options.renotify } : {}),\n };\n}\n\nfunction toArrayBuffer(bytes: Uint8Array): ArrayBuffer {\n return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;\n}\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((sum, part) => sum + part.length, 0);\n const out = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nfunction uint32Be(value: number): Uint8Array {\n const out = new Uint8Array(4);\n new DataView(out.buffer).setUint32(0, value, false);\n return out;\n}\n\nfunction ecPublicToJwk(publicKey: Uint8Array): JsonWebKey {\n if (publicKey.length !== 65 || publicKey[0] !== 0x04) {\n throw new WebPushError(\"Invalid P-256 uncompressed public key\", 400, {\n length: publicKey.length,\n });\n }\n return {\n kty: \"EC\",\n crv: \"P-256\",\n x: encodeBase64Url(publicKey.subarray(1, 33)),\n y: encodeBase64Url(publicKey.subarray(33, 65)),\n };\n}\n\nfunction ecKeyPairToJwk(publicKey: Uint8Array, privateKey: Uint8Array): JsonWebKey {\n return {\n ...ecPublicToJwk(publicKey),\n d: encodeBase64Url(privateKey),\n };\n}\n\nasync function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(key),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const sig = await crypto.subtle.sign(\"HMAC\", cryptoKey, toArrayBuffer(data));\n return new Uint8Array(sig);\n}\n\n/** HKDF-Extract + Expand (HMAC-SHA-256) for a single block (L ≤ 32). */\nasync function hkdf(\n salt: Uint8Array,\n ikm: Uint8Array,\n info: Uint8Array,\n length: number,\n): Promise<Uint8Array> {\n const prk = await hmacSha256(salt, ikm);\n const block = await hmacSha256(prk, concatBytes(info, new Uint8Array([0x01])));\n return block.subarray(0, length);\n}\n\n/**\n * Encrypt a payload per RFC 8291 (aes128gcm content coding).\n *\n * Confidence note: Implemented from RFC 8291 / RFC 8188. Tests assert header\n * shapes, framing, and size limits; full known-vector fixtures were not\n * available in-repo — treat as high-scrutiny crypto.\n */\nasync function encryptAes128Gcm(\n subscription: PushSubscription,\n plaintext: Uint8Array,\n): Promise<Uint8Array> {\n const uaPublic = decodeBase64Url(subscription.keys.p256dh);\n const authSecret = decodeBase64Url(subscription.keys.auth);\n\n if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {\n throw new WebPushError(\"Invalid subscription p256dh key\", 400, {\n length: uaPublic.length,\n });\n }\n if (authSecret.length < 16) {\n throw new WebPushError(\"Invalid subscription auth key\", 400, {\n length: authSecret.length,\n });\n }\n\n const asKeyPair = await crypto.subtle.generateKey({ name: \"ECDH\", namedCurve: \"P-256\" }, true, [\n \"deriveBits\",\n ]);\n const asPublicRaw = new Uint8Array(await crypto.subtle.exportKey(\"raw\", asKeyPair.publicKey));\n\n const uaKey = await crypto.subtle.importKey(\n \"raw\",\n toArrayBuffer(uaPublic),\n { name: \"ECDH\", namedCurve: \"P-256\" },\n false,\n [],\n );\n\n const ecdhSecret = new Uint8Array(\n await crypto.subtle.deriveBits({ name: \"ECDH\", public: uaKey }, asKeyPair.privateKey, 256),\n );\n\n const keyInfo = concatBytes(encodeUtf8(\"WebPush: info\\0\"), uaPublic, asPublicRaw);\n const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32);\n\n const salt = crypto.getRandomValues(new Uint8Array(16));\n const cekInfo = encodeUtf8(\"Content-Encoding: aes128gcm\\0\");\n const nonceInfo = encodeUtf8(\"Content-Encoding: nonce\\0\");\n const cek = await hkdf(salt, ikm, cekInfo, 16);\n const nonce = await hkdf(salt, ikm, nonceInfo, 12);\n\n // RFC 8188 final-record padding delimiter\n const padded = concatBytes(plaintext, new Uint8Array([0x02]));\n\n const aesKey = await crypto.subtle.importKey(\"raw\", toArrayBuffer(cek), \"AES-GCM\", false, [\n \"encrypt\",\n ]);\n const ciphertext = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: \"AES-GCM\", iv: toArrayBuffer(nonce) },\n aesKey,\n toArrayBuffer(padded),\n ),\n );\n\n // salt || rs || idlen || keyid || ciphertext\n return concatBytes(\n salt,\n uint32Be(RECORD_SIZE),\n new Uint8Array([asPublicRaw.length]),\n asPublicRaw,\n ciphertext,\n );\n}\n\nasync function signVapidJwt(\n endpoint: string,\n subject: string,\n vapidPublicKey: string,\n vapidPrivateKey: string,\n): Promise<string> {\n const publicKey = decodeBase64Url(vapidPublicKey);\n const privateKey = decodeBase64Url(vapidPrivateKey);\n const jwk = ecKeyPairToJwk(publicKey, privateKey);\n\n const key = await crypto.subtle.importKey(\n \"jwk\",\n jwk,\n { name: \"ECDSA\", namedCurve: \"P-256\" },\n false,\n [\"sign\"],\n );\n\n const origin = new URL(endpoint).origin;\n const header = { typ: \"JWT\", alg: \"ES256\" };\n const payload = {\n aud: origin,\n exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,\n sub: subject,\n };\n\n const encodedHeader = encodeBase64Url(encodeUtf8(JSON.stringify(header)));\n const encodedPayload = encodeBase64Url(encodeUtf8(JSON.stringify(payload)));\n const signingInput = `${encodedHeader}.${encodedPayload}`;\n\n const signature = new Uint8Array(\n await crypto.subtle.sign(\n { name: \"ECDSA\", hash: \"SHA-256\" },\n key,\n toArrayBuffer(encodeUtf8(signingInput)),\n ),\n );\n\n return `${signingInput}.${encodeBase64Url(signature)}`;\n}\n\n/**\n * Web Push transport — VAPID auth + RFC 8291 payload encryption.\n *\n * Endpoint URLs are validated against an allowlist before fetch to mitigate SSRF.\n * Redirects are not followed.\n */\nexport class WebPushTransport implements PushTransport {\n readonly provider = \"webpush\";\n\n private readonly vapidPublicKey: string;\n private readonly vapidPrivateKey: string;\n private readonly subject: string;\n private readonly allowedEndpointHosts: readonly string[];\n\n /** Creates a Web Push transport with VAPID credentials. */\n constructor(config: WebPushConfig) {\n assertVapidSubject(config.subject);\n this.vapidPublicKey = config.vapidPublicKey;\n this.vapidPrivateKey = config.vapidPrivateKey;\n this.subject = config.subject;\n this.allowedEndpointHosts = config.allowedEndpointHosts ?? [];\n }\n\n /** Encrypts and POSTs a notification to the subscription endpoint. */\n async send(options: PushOptions): Promise<PushSendResult> {\n if (!isWebPushOptions(options)) {\n throw new WebPushError(\n \"WebPushTransport requires PushOptions.subscription; use FcmTransport for device tokens\",\n 400,\n { hint: \"fcm\" },\n );\n }\n\n try {\n assertSafePushEndpoint(options.subscription.endpoint, this.allowedEndpointHosts);\n } catch (error) {\n let endpointHost: string | undefined;\n try {\n endpointHost = new URL(options.subscription.endpoint).hostname;\n } catch {\n endpointHost = undefined;\n }\n throw new WebPushError(\n error instanceof Error ? error.message : \"Unsafe push subscription endpoint\",\n 400,\n { endpointHost },\n );\n }\n\n if (options.urgency !== undefined) {\n assertUrgency(options.urgency);\n }\n if (options.topic !== undefined) {\n assertTopic(options.topic);\n }\n\n const notification = buildWebPushPayload(options);\n const plaintext = encodeUtf8(JSON.stringify(notification));\n const encrypted = await encryptAes128Gcm(options.subscription, plaintext);\n\n if (encrypted.length > MAX_ENCRYPTED_BODY_BYTES) {\n throw new WebPushError(\n `Encrypted push payload exceeds ${MAX_ENCRYPTED_BODY_BYTES} bytes (RFC 8291)`,\n 413,\n { size: encrypted.length },\n );\n }\n\n const jwt = await signVapidJwt(\n options.subscription.endpoint,\n this.subject,\n this.vapidPublicKey,\n this.vapidPrivateKey,\n );\n\n const ttl = options.ttl ?? DEFAULT_TTL_SECONDS;\n const headers: Record<string, string> = {\n Authorization: `vapid t=${jwt}, k=${this.vapidPublicKey}`,\n \"Content-Encoding\": \"aes128gcm\",\n TTL: String(ttl),\n \"Content-Type\": \"application/octet-stream\",\n };\n if (options.urgency !== undefined) {\n headers.Urgency = options.urgency;\n }\n if (options.topic !== undefined) {\n headers.Topic = options.topic;\n }\n\n const response = await fetch(options.subscription.endpoint, {\n method: \"POST\",\n redirect: \"manual\",\n headers,\n body: toArrayBuffer(encrypted),\n });\n\n // Opaque redirect responses (3xx) must not be followed — SSRF mitigation.\n if (response.status >= 300 && response.status < 400) {\n throw new WebPushError(\n `Push service returned redirect ${response.status}; redirects are not followed`,\n response.status,\n { location: response.headers.get(\"location\") },\n );\n }\n\n if (!response.ok) {\n const bodyText = await response.text().catch(() => \"\");\n throw new WebPushError(\n bodyText || `Web Push request failed with status ${response.status}`,\n response.status,\n bodyText || { status: response.status },\n );\n }\n\n return {\n messageId: options.messageId ?? crypto.randomUUID(),\n status: \"accepted\",\n response: String(response.status),\n provider: \"webpush\",\n };\n }\n\n /** Lightweight VAPID credential shape check. */\n async verify(): Promise<VerifyResult> {\n try {\n const pub = decodeBase64Url(this.vapidPublicKey);\n const priv = decodeBase64Url(this.vapidPrivateKey);\n const ok = pub.length === 65 && priv.length === 32 && this.subject.length > 0;\n return {\n ok,\n provider: \"webpush\",\n message: ok ? \"VAPID credentials present\" : \"Invalid VAPID key lengths\",\n };\n } catch (error) {\n return {\n ok: false,\n provider: \"webpush\",\n message: error instanceof Error ? error.message : \"Invalid VAPID credentials\",\n };\n }\n }\n}\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "4593CBC2A87BBFF364756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "4FE1E8A522321CE464756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -1,3 +1,3 @@
1
- import{a as W,c as _}from"../chunk-pqp843mh.js";import{f as U,g as X,i as j}from"../chunk-sbgt4165.js";import"../chunk-th4cwrpb.js";var x={"email.delivered":"delivered","email.bounced":"bounced","email.unsubscribed":"unknown"};function P(K){if(typeof K!=="object"||K===null)return[];let I=K,z=typeof I.type==="string"?I.type:"",L=typeof I.data==="object"&&I.data!==null?I.data:{},G=L.to??L.addresses,Q=Array.isArray(G)?typeof G[0]==="string"?G[0]:void 0:typeof G==="string"?G:void 0,Z=X(I.created_at);return[j({provider:"sndr",type:U(z,x),...typeof L.email_id==="string"?{messageId:L.email_id}:{},...Q!==void 0?{recipient:Q}:{},...Z!==void 0?{timestamp:Z}:{},raw:K})]}async function R(K,I,z,L){if(!I||!z)return!1;let G,Q;for(let Y of I.split(",")){let $=Y.indexOf("=");if($<0)continue;let J=Y.slice(0,$).trim(),O=Y.slice($+1).trim();if(J==="t")G=O;else if(J==="v1")Q=O}if(!G||!Q)return!1;let Z=L?.toleranceSeconds??300;if(Z>0){let Y=Number(G);if(!Number.isFinite(Y))return!1;let $=Math.floor(Date.now()/1000);if(Math.abs($-Y)>Z)return!1}let F=_(Q);if(F===null)return!1;let C=await crypto.subtle.importKey("raw",new TextEncoder().encode(z),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),D=new TextEncoder().encode(`${G}.${K}`),V=await crypto.subtle.sign("HMAC",C,D);return W(new Uint8Array(V),F)}export{R as verifySignature,P as parse};
1
+ import{a as W,c as _}from"../chunk-pqp843mh.js";import{f as U,g as X,i as j}from"../chunk-sbgt4165.js";import"../chunk-th4cwrpb.js";var x={"email.queued":"deferred","email.delivered":"delivered","email.bounced":"bounced","email.failed":"unknown","email.complained":"complained","email.opened":"opened","email.clicked":"clicked","email.unsubscribed":"unknown"};function P(K){if(typeof K!=="object"||K===null)return[];let I=K,z=typeof I.type==="string"?I.type:"",L=typeof I.data==="object"&&I.data!==null?I.data:{},G=L.to??L.addresses,Q=Array.isArray(G)?typeof G[0]==="string"?G[0]:void 0:typeof G==="string"?G:void 0,Z=X(I.created_at);return[j({provider:"sndr",type:U(z,x),...typeof L.email_id==="string"?{messageId:L.email_id}:{},...Q!==void 0?{recipient:Q}:{},...Z!==void 0?{timestamp:Z}:{},raw:K})]}async function R(K,I,z,L){if(!I||!z)return!1;let G,Q;for(let Y of I.split(",")){let $=Y.indexOf("=");if($<0)continue;let J=Y.slice(0,$).trim(),O=Y.slice($+1).trim();if(J==="t")G=O;else if(J==="v1")Q=O}if(!G||!Q)return!1;let Z=L?.toleranceSeconds??300;if(Z>0){let Y=Number(G);if(!Number.isFinite(Y))return!1;let $=Math.floor(Date.now()/1000);if(Math.abs($-Y)>Z)return!1}let F=_(Q);if(F===null)return!1;let C=await crypto.subtle.importKey("raw",new TextEncoder().encode(z),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),D=new TextEncoder().encode(`${G}.${K}`),V=await crypto.subtle.sign("HMAC",C,D);return W(new Uint8Array(V),F)}export{R as verifySignature,P as parse};
2
2
 
3
- //# debugId=894DC903F2CF2D6664756E2164756E21
3
+ //# debugId=6092528FFA26D54664756E2164756E21
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/webhooks/sndr.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * @module\n * SNDR webhook parser and signature verification.\n *\n * Signature scheme (from `@rkiza/sndr` / SNDR dispatcher):\n * `X-Sndr-Signature: t=<unix_seconds>,v1=<hex(HMAC-SHA256(secret, `${t}.${rawBody}`))>`\n */\nimport { decodeHexBytes, timingSafeEqual } from \"./timing-safe-equal.js\";\nimport type { EmailEvent } from \"./types.js\";\nimport { emailEvent, mapEventType, parseTimestamp } from \"./types.js\";\n\nconst SNDR_EVENT_MAP: Record<string, EmailEvent[\"type\"]> = {\n \"email.delivered\": \"delivered\",\n \"email.bounced\": \"bounced\",\n \"email.unsubscribed\": \"unknown\",\n};\n\n/** Parse an SNDR webhook payload into normalized {@link EmailEvent} records. */\nexport function parse(payload: unknown): EmailEvent[] {\n if (typeof payload !== \"object\" || payload === null) {\n return [];\n }\n\n const record = payload as Record<string, unknown>;\n const type = typeof record.type === \"string\" ? record.type : \"\";\n const data =\n typeof record.data === \"object\" && record.data !== null\n ? (record.data as Record<string, unknown>)\n : {};\n\n const to = data.to ?? data.addresses;\n const recipient = Array.isArray(to)\n ? typeof to[0] === \"string\"\n ? to[0]\n : undefined\n : typeof to === \"string\"\n ? to\n : undefined;\n\n const timestamp = parseTimestamp(record.created_at);\n\n return [\n emailEvent({\n provider: \"sndr\",\n type: mapEventType(type, SNDR_EVENT_MAP),\n ...(typeof data.email_id === \"string\" ? { messageId: data.email_id } : {}),\n ...(recipient !== undefined ? { recipient } : {}),\n ...(timestamp !== undefined ? { timestamp } : {}),\n raw: payload,\n }),\n ];\n}\n\n/**\n * Verify an SNDR webhook signature using HMAC-SHA256 (Web Crypto).\n *\n * Pass the **exact** raw request body string — re-serializing JSON breaks the HMAC.\n *\n * @param rawBody — Raw HTTP body bytes as UTF-8 string (or the string used when signing).\n * @param signatureHeader — Value of the `X-Sndr-Signature` header (`t=…,v1=…`).\n * @param secret — Webhook endpoint signing secret from SNDR.\n * @param opts.toleranceSeconds — Max age of `t` in seconds (default `300`). Pass `0` to skip.\n */\nexport async function verifySignature(\n rawBody: string,\n signatureHeader: string,\n secret: string,\n opts?: { toleranceSeconds?: number },\n): Promise<boolean> {\n if (!signatureHeader || !secret) {\n return false;\n }\n\n let timestamp: string | undefined;\n let v1: string | undefined;\n for (const part of signatureHeader.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = value;\n else if (key === \"v1\") v1 = value;\n }\n\n if (!timestamp || !v1) {\n return false;\n }\n\n const tolerance = opts?.toleranceSeconds ?? 300;\n if (tolerance > 0) {\n const tsNum = Number(timestamp);\n if (!Number.isFinite(tsNum)) {\n return false;\n }\n const nowSec = Math.floor(Date.now() / 1000);\n if (Math.abs(nowSec - tsNum) > tolerance) {\n return false;\n }\n }\n\n const providedBytes = decodeHexBytes(v1);\n if (providedBytes === null) {\n return false;\n }\n\n const key = await crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n\n const signed = new TextEncoder().encode(`${timestamp}.${rawBody}`);\n const expected = await crypto.subtle.sign(\"HMAC\", key, signed);\n return timingSafeEqual(new Uint8Array(expected), providedBytes);\n}\n"
5
+ "/**\n * @module\n * SNDR webhook parser and signature verification.\n *\n * Signature scheme (from `@rkiza/sndr` / SNDR dispatcher):\n * `X-Sndr-Signature: t=<unix_seconds>,v1=<hex(HMAC-SHA256(secret, `${t}.${rawBody}`))>`\n */\nimport { decodeHexBytes, timingSafeEqual } from \"./timing-safe-equal.js\";\nimport type { EmailEvent } from \"./types.js\";\nimport { emailEvent, mapEventType, parseTimestamp } from \"./types.js\";\n\nconst SNDR_EVENT_MAP: Record<string, EmailEvent[\"type\"]> = {\n \"email.queued\": \"deferred\",\n \"email.delivered\": \"delivered\",\n \"email.bounced\": \"bounced\",\n \"email.failed\": \"unknown\",\n \"email.complained\": \"complained\",\n \"email.opened\": \"opened\",\n \"email.clicked\": \"clicked\",\n \"email.unsubscribed\": \"unknown\",\n};\n\n/** Parse an SNDR webhook payload into normalized {@link EmailEvent} records. */\nexport function parse(payload: unknown): EmailEvent[] {\n if (typeof payload !== \"object\" || payload === null) {\n return [];\n }\n\n const record = payload as Record<string, unknown>;\n const type = typeof record.type === \"string\" ? record.type : \"\";\n const data =\n typeof record.data === \"object\" && record.data !== null\n ? (record.data as Record<string, unknown>)\n : {};\n\n const to = data.to ?? data.addresses;\n const recipient = Array.isArray(to)\n ? typeof to[0] === \"string\"\n ? to[0]\n : undefined\n : typeof to === \"string\"\n ? to\n : undefined;\n\n const timestamp = parseTimestamp(record.created_at);\n\n return [\n emailEvent({\n provider: \"sndr\",\n type: mapEventType(type, SNDR_EVENT_MAP),\n ...(typeof data.email_id === \"string\" ? { messageId: data.email_id } : {}),\n ...(recipient !== undefined ? { recipient } : {}),\n ...(timestamp !== undefined ? { timestamp } : {}),\n raw: payload,\n }),\n ];\n}\n\n/**\n * Verify an SNDR webhook signature using HMAC-SHA256 (Web Crypto).\n *\n * Pass the **exact** raw request body string — re-serializing JSON breaks the HMAC.\n *\n * @param rawBody — Raw HTTP body bytes as UTF-8 string (or the string used when signing).\n * @param signatureHeader — Value of the `X-Sndr-Signature` header (`t=…,v1=…`).\n * @param secret — Webhook endpoint signing secret from SNDR.\n * @param opts.toleranceSeconds — Max age of `t` in seconds (default `300`). Pass `0` to skip.\n */\nexport async function verifySignature(\n rawBody: string,\n signatureHeader: string,\n secret: string,\n opts?: { toleranceSeconds?: number },\n): Promise<boolean> {\n if (!signatureHeader || !secret) {\n return false;\n }\n\n let timestamp: string | undefined;\n let v1: string | undefined;\n for (const part of signatureHeader.split(\",\")) {\n const eq = part.indexOf(\"=\");\n if (eq < 0) continue;\n const key = part.slice(0, eq).trim();\n const value = part.slice(eq + 1).trim();\n if (key === \"t\") timestamp = value;\n else if (key === \"v1\") v1 = value;\n }\n\n if (!timestamp || !v1) {\n return false;\n }\n\n const tolerance = opts?.toleranceSeconds ?? 300;\n if (tolerance > 0) {\n const tsNum = Number(timestamp);\n if (!Number.isFinite(tsNum)) {\n return false;\n }\n const nowSec = Math.floor(Date.now() / 1000);\n if (Math.abs(nowSec - tsNum) > tolerance) {\n return false;\n }\n }\n\n const providedBytes = decodeHexBytes(v1);\n if (providedBytes === null) {\n return false;\n }\n\n const key = await crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n\n const signed = new TextEncoder().encode(`${timestamp}.${rawBody}`);\n const expected = await crypto.subtle.sign(\"HMAC\", key, signed);\n return timingSafeEqual(new Uint8Array(expected), providedBytes);\n}\n"
6
6
  ],
7
- "mappings": "8IAWA,DAAM,HAAqD,CACzD,kBAAmB,YACnB,gBAAiB,UACjB,qBAAsB,SACxB,EAGO,SAAS,CAAK,CAAC,EAAgC,CACpD,GAAI,OAAO,IAAY,UAAY,IAAY,KAC7C,MAAO,CAAC,EAGV,IAAM,EAAS,EACT,EAAO,OAAO,EAAO,OAAS,SAAW,EAAO,KAAO,GACvD,EACJ,OAAO,EAAO,OAAS,UAAY,EAAO,OAAS,KAC9C,EAAO,KACR,CAAC,EAED,EAAK,EAAK,IAAM,EAAK,UACrB,EAAY,MAAM,QAAQ,CAAE,EAC9B,OAAO,EAAG,KAAO,SACf,EAAG,GACH,OACF,OAAO,IAAO,SACZ,EACA,OAEA,EAAY,EAAe,EAAO,UAAU,EAElD,MAAO,CACL,EAAW,CACT,SAAU,OACV,KAAM,EAAa,EAAM,CAAc,KACnC,OAAO,EAAK,WAAa,SAAW,CAAE,UAAW,EAAK,QAAS,EAAI,CAAC,KACpE,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,KAC3C,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,EAC/C,IAAK,CACP,CAAC,CACH,EAaF,eAAsB,CAAe,CACnC,EACA,EACA,EACA,EACkB,CAClB,GAAI,CAAC,GAAmB,CAAC,EACvB,MAAO,GAGT,IAAI,EACA,EACJ,QAAW,KAAQ,EAAgB,MAAM,GAAG,EAAG,CAC7C,IAAM,EAAK,EAAK,QAAQ,GAAG,EAC3B,GAAI,EAAK,EAAG,SACZ,IAAM,EAAM,EAAK,MAAM,EAAG,CAAE,EAAE,KAAK,EAC7B,EAAQ,EAAK,MAAM,EAAK,CAAC,EAAE,KAAK,EACtC,GAAI,IAAQ,IAAK,EAAY,EACxB,QAAI,IAAQ,KAAM,EAAK,EAG9B,GAAI,CAAC,GAAa,CAAC,EACjB,MAAO,GAGT,IAAM,EAAY,GAAM,kBAAoB,IAC5C,GAAI,EAAY,EAAG,CACjB,IAAM,EAAQ,OAAO,CAAS,EAC9B,GAAI,CAAC,OAAO,SAAS,CAAK,EACxB,MAAO,GAET,IAAM,EAAS,KAAK,MAAM,KAAK,IAAI,EAAI,IAAI,EAC3C,GAAI,KAAK,IAAI,EAAS,CAAK,EAAI,EAC7B,MAAO,GAIX,IAAM,EAAgB,EAAe,CAAE,EACvC,GAAI,IAAkB,KACpB,MAAO,GAGT,IAAM,EAAM,MAAM,OAAO,OAAO,UAC9B,MACA,IAAI,YAAY,EAAE,OAAO,CAAM,EAC/B,CAAE,KAAM,OAAQ,KAAM,SAAU,EAChC,GACA,CAAC,MAAM,CACT,EAEM,EAAS,IAAI,YAAY,EAAE,OAAO,GAAG,KAAa,GAAS,EAC3D,EAAW,MAAM,OAAO,OAAO,KAAK,OAAQ,EAAK,CAAM,EAC7D,OAAO,EAAgB,IAAI,WAAW,CAAQ,EAAG,CAAa",
8
- "debugId": "894DC903F2CF2D6664756E2164756E21",
7
+ "mappings": "8IAWA,DAAM,HAAqD,CACzD,eAAgB,WAChB,kBAAmB,YACnB,gBAAiB,UACjB,eAAgB,UAChB,mBAAoB,aACpB,eAAgB,SAChB,gBAAiB,UACjB,qBAAsB,SACxB,EAGO,SAAS,CAAK,CAAC,EAAgC,CACpD,GAAI,OAAO,IAAY,UAAY,IAAY,KAC7C,MAAO,CAAC,EAGV,IAAM,EAAS,EACT,EAAO,OAAO,EAAO,OAAS,SAAW,EAAO,KAAO,GACvD,EACJ,OAAO,EAAO,OAAS,UAAY,EAAO,OAAS,KAC9C,EAAO,KACR,CAAC,EAED,EAAK,EAAK,IAAM,EAAK,UACrB,EAAY,MAAM,QAAQ,CAAE,EAC9B,OAAO,EAAG,KAAO,SACf,EAAG,GACH,OACF,OAAO,IAAO,SACZ,EACA,OAEA,EAAY,EAAe,EAAO,UAAU,EAElD,MAAO,CACL,EAAW,CACT,SAAU,OACV,KAAM,EAAa,EAAM,CAAc,KACnC,OAAO,EAAK,WAAa,SAAW,CAAE,UAAW,EAAK,QAAS,EAAI,CAAC,KACpE,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,KAC3C,IAAc,OAAY,CAAE,WAAU,EAAI,CAAC,EAC/C,IAAK,CACP,CAAC,CACH,EAaF,eAAsB,CAAe,CACnC,EACA,EACA,EACA,EACkB,CAClB,GAAI,CAAC,GAAmB,CAAC,EACvB,MAAO,GAGT,IAAI,EACA,EACJ,QAAW,KAAQ,EAAgB,MAAM,GAAG,EAAG,CAC7C,IAAM,EAAK,EAAK,QAAQ,GAAG,EAC3B,GAAI,EAAK,EAAG,SACZ,IAAM,EAAM,EAAK,MAAM,EAAG,CAAE,EAAE,KAAK,EAC7B,EAAQ,EAAK,MAAM,EAAK,CAAC,EAAE,KAAK,EACtC,GAAI,IAAQ,IAAK,EAAY,EACxB,QAAI,IAAQ,KAAM,EAAK,EAG9B,GAAI,CAAC,GAAa,CAAC,EACjB,MAAO,GAGT,IAAM,EAAY,GAAM,kBAAoB,IAC5C,GAAI,EAAY,EAAG,CACjB,IAAM,EAAQ,OAAO,CAAS,EAC9B,GAAI,CAAC,OAAO,SAAS,CAAK,EACxB,MAAO,GAET,IAAM,EAAS,KAAK,MAAM,KAAK,IAAI,EAAI,IAAI,EAC3C,GAAI,KAAK,IAAI,EAAS,CAAK,EAAI,EAC7B,MAAO,GAIX,IAAM,EAAgB,EAAe,CAAE,EACvC,GAAI,IAAkB,KACpB,MAAO,GAGT,IAAM,EAAM,MAAM,OAAO,OAAO,UAC9B,MACA,IAAI,YAAY,EAAE,OAAO,CAAM,EAC/B,CAAE,KAAM,OAAQ,KAAM,SAAU,EAChC,GACA,CAAC,MAAM,CACT,EAEM,EAAS,IAAI,YAAY,EAAE,OAAO,GAAG,KAAa,GAAS,EAC3D,EAAW,MAAM,OAAO,OAAO,KAAK,OAAQ,EAAK,CAAM,EAC7D,OAAO,EAAgB,IAAI,WAAW,CAAQ,EAAG,CAAa",
8
+ "debugId": "6092528FFA26D54664756E2164756E21",
9
9
  "names": []
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sently",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Runtime-agnostic channel-delivery library for Node.js, Bun, Deno, and Cloudflare Workers. One sender shape, one error model, and one retry path across email, SMS, WhatsApp, and push — with pluggable provider transports.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -24,8 +24,9 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "build": "bun run build.ts",
27
- "test": "bun test",
27
+ "test": "TAQNYAT_LIVE= SNDR_LIVE= INBUCKET_LIVE= bun test --path-ignore-patterns='**/*.live.test.ts'",
28
28
  "test:watch": "bun test --watch",
29
+ "test:live": "bun -e \"if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error('test:live is local-only'); process.exit(1); }\" && bun test tests/integration/*.live.test.ts",
29
30
  "lint": "biome check src",
30
31
  "format": "biome format src --write",
31
32
  "typecheck": "tsc --noEmit",
@@ -83,6 +84,7 @@
83
84
  "ses",
84
85
  "aws-ses",
85
86
  "brevo",
87
+ "hostinger",
86
88
  "twilio",
87
89
  "dkim",
88
90
  "oauth2",
@@ -192,6 +194,10 @@
192
194
  "import": "./dist/transports/mailpit.js",
193
195
  "types": "./dist/transports/mailpit.d.ts"
194
196
  },
197
+ "./transports/inbucket": {
198
+ "import": "./dist/transports/inbucket.js",
199
+ "types": "./dist/transports/inbucket.d.ts"
200
+ },
195
201
  "./transports/loops": {
196
202
  "import": "./dist/transports/loops.js",
197
203
  "types": "./dist/transports/loops.d.ts"
@@ -200,6 +206,10 @@
200
206
  "import": "./dist/transports/sndr.js",
201
207
  "types": "./dist/transports/sndr.d.ts"
202
208
  },
209
+ "./transports/hostinger": {
210
+ "import": "./dist/transports/hostinger.js",
211
+ "types": "./dist/transports/hostinger.d.ts"
212
+ },
203
213
  "./transports/twilio-sms": {
204
214
  "import": "./dist/transports/twilio-sms.js",
205
215
  "types": "./dist/transports/twilio-sms.d.ts"
@@ -8,6 +8,8 @@ source: "site/app/llms.txt/route.ts"
8
8
  The docs site serves `/llms.txt` and `/llms-full.txt` for concise and complete machine-readable documentation.
9
9
  Use the live site indexes when an agent needs current handbook navigation.
10
10
 
11
+ Site `/llms.txt` opens with a short agent preamble (what sently is — notification and messaging infrastructure for TypeScript, not a message queue), then the Fumadocs handbook index.
12
+
11
13
  <Callout title="The one rule">
12
14
  Prefer the docs site `/llms.txt` over the repo-root `llms.txt` file when both exist — the site index is generated from the handbook MDX.
13
15
  </Callout>
@@ -92,12 +92,13 @@ const mailer = await createSMTPMailer({
92
92
  - [Transport contracts](../reference/transport-contracts)
93
93
  - [Hooks](./hooks)
94
94
  - [Mailpit](../transports/mailpit) — local catcher for development
95
+ - [Inbucket](../transports/inbucket) — local catcher with mailbox REST API
95
96
  - [Preview](../decorators/preview) — write `.eml` files to disk
96
97
 
97
98
  ## Next
98
99
 
99
100
  <Cards>
100
101
  <Card title="Transports" href="/docs/transports" />
101
- <Card title="Mailpit" href="/docs/transports/mailpit" />
102
+ <Card title="Inbucket" href="/docs/transports/inbucket" />
102
103
  <Card title="Send in bulk" href="/docs/guides/send-bulk" />
103
104
  </Cards>
@@ -70,7 +70,9 @@ await push.send({
70
70
  | Web Push | `subscription` | `WebPushTransport` |
71
71
  | FCM | `token` | `FcmTransport` |
72
72
 
73
- Shared fields: `title`, `body`, optional `data`, `icon`, `ttl`, `messageId`.
73
+ Shared fields include `title`, `body`, and optional `data` / `icon` / `ttl`.
74
+ Web Push also accepts `urgency`, `topic`, rich Notification fields, and silent /
75
+ data-only sends — see [Push options](../reference/push-options).
74
76
 
75
77
  ## Troubleshooting
76
78
 
@@ -50,7 +50,7 @@ Files land under `outDir` (default `./.emails`). With `open: true`, the OS opens
50
50
 
51
51
  `provider` is `"preview"`. `verify()` always succeeds.
52
52
 
53
- For a real SMTP catcher with a web UI, use [Mailpit](/docs/transports/mailpit) instead.
53
+ For a real SMTP catcher with a web UI, use [Mailpit](/docs/transports/mailpit) or [Inbucket](/docs/transports/inbucket) instead.
54
54
 
55
55
  ## Troubleshooting
56
56
 
@@ -66,6 +66,7 @@ For a real SMTP catcher with a web UI, use [Mailpit](/docs/transports/mailpit) i
66
66
  ## Learn more
67
67
 
68
68
  - [Mailpit](/docs/transports/mailpit) — local SMTP catcher with inbox API
69
+ - [Inbucket](/docs/transports/inbucket) — local SMTP catcher with mailbox REST API
69
70
  - [Email channel](/docs/channels/email) — `createMailer` contract
70
71
 
71
72
  ## Next
@@ -88,7 +88,7 @@ No. `createMailer` requires a transport. Import `createSMTPMailer` from `sently/
88
88
 
89
89
  <Accordion title="Where do provider transports come from?">
90
90
 
91
- Import the concrete transport from its exported `sently/transports/*` subpath, such as `sently/transports/sndr`, `sently/transports/resend`, or `sently/transports/mailpit`.
91
+ Import the concrete transport from its exported `sently/transports/*` subpath, such as `sently/transports/sndr`, `sently/transports/resend`, or `sently/transports/inbucket`.
92
92
 
93
93
  </Accordion>
94
94
 
@@ -29,11 +29,11 @@ FCM uses the current Firebase HTTP API (service-account JWT, no Google SDK). Tha
29
29
 
30
30
  | Channel | Available examples |
31
31
  | --- | --- |
32
- | Email | Mailgun, Brevo, MailerSend, Plunk, SparkPost, Mailtrap, Mailpit (dev), Loops, SNDR, Taqnyat Mail, Cloudflare Email, … |
32
+ | Email | Mailgun, Brevo, MailerSend, Plunk, SparkPost, Mailtrap, Mailpit (dev), Inbucket (dev), Loops, SNDR, Hostinger, Taqnyat Mail, Cloudflare Email, … |
33
33
  | SMS | Taqnyat SMS, Msegat |
34
34
  | WhatsApp | Taqnyat WhatsApp |
35
35
  | Decorators | `WeightedFallbackTransport` (advanced); preview / idempotency remain email-only |
36
- | Local email | [Mailpit](/docs/transports/mailpit) (SMTP catcher), [Preview](/docs/decorators/preview) (disk) |
36
+ | Local email | [Mailpit](/docs/transports/mailpit) / [Inbucket](/docs/transports/inbucket) (SMTP catchers), [Preview](/docs/decorators/preview) (disk) |
37
37
 
38
38
  Available transports stay in the package. They are promoted to Supported when docs, operability, and smoke coverage meet the bar above.
39
39
 
@@ -14,5 +14,6 @@ await msegat.verifyOtp({ id: otp.id, code: "1234", lang: "En" });
14
14
  ```
15
15
 
16
16
  Taqnyat SMS also exposes `sendOtp` and `verifyOtp` on its transport.
17
+ See [Taqnyat](/docs/transports/taqnyat#sms-vendor-extras) for balance, senders, schedule, WhatsApp templates, opt-in, and failover extras.
17
18
 
18
- <Cards><Card title="Msegat" href="/docs/transports/msegat" /><Card title="Taqnyat SMS" href="/docs/transports/taqnyat-sms" /></Cards>
19
+ <Cards><Card title="Msegat" href="/docs/transports/msegat" /><Card title="Taqnyat" href="/docs/transports/taqnyat#sms" /></Cards>
@@ -99,6 +99,8 @@ The digest is HMAC-SHA256 over `` `${t}.${rawBody}` ``.
99
99
 
100
100
  **Consequence:** Re-serializing JSON changes whitespace and key order and breaks verification — always use the raw body string.
101
101
 
102
+ Normalized event types include queued → `deferred`, delivered, bounced, complained, opened, clicked, and failed / unsubscribed → `unknown`. See [SNDR](/docs/transports/sndr#webhooks).
103
+
102
104
  Default timestamp tolerance is 300 seconds. Pass `{ toleranceSeconds: 0 }` to disable the freshness check.
103
105
 
104
106
  ## Troubleshooting
@@ -8,10 +8,18 @@ source: "src/transports/webpush.ts"
8
8
  <Callout title="The one rule">Pass the Push API subscription endpoint and both keys exactly as the browser returned them.</Callout>
9
9
 
10
10
  The transport accepts the standard `endpoint`, `keys.p256dh`, and `keys.auth` shape and encrypts payloads using Web Push standards.
11
+ Generate server keys with `generateVapidKeys()` from `sently/transports/webpush` and use the same public key in `pushManager.subscribe({ applicationServerKey })`.
11
12
 
12
13
  ## Troubleshooting
13
14
 
14
- <Accordions><Accordion title="Can I allow a private push relay?">Provide exact hostnames with `allowedEndpointHosts`.</Accordion></Accordions>
15
+ <Accordions>
16
+ <Accordion title="Can I allow a private push relay?">
17
+ Provide exact hostnames with `allowedEndpointHosts`.
18
+ </Accordion>
19
+ <Accordion title="Can I send without showing a notification?">
20
+ Pass `data` without `title` / `body`, or set `silent: true` with `data`. The service worker must handle `push` without calling `showNotification`.
21
+ </Accordion>
22
+ </Accordions>
15
23
 
16
24
  ## Next
17
25
 
@@ -37,7 +37,7 @@ The main `sently` package is for shared types and factories — not every provid
37
37
 
38
38
  | Import | Use |
39
39
  | ------ | --- |
40
- | `sently/transports/<name>` | One provider or decorator (e.g. `sndr`, `mailpit`, `fcm`, `retry`) |
40
+ | `sently/transports/<name>` | One provider or decorator (e.g. `sndr`, `inbucket`, `fcm`, `retry`) |
41
41
 
42
42
  HTTP providers such as SNDR, Resend, and Plunk are **not** re-exported from `sently`.
43
43
 
@@ -16,9 +16,9 @@ Pick the shape that matches your transport.
16
16
 
17
17
  | Field | Type | Required |
18
18
  | --- | --- | --- |
19
- | `title` | `string` | yes |
20
- | `body` | `string` | yes |
21
- | `data` | `Record<string, unknown>` | no |
19
+ | `title` | `string` | FCM always; Web Push for visible notifications |
20
+ | `body` | `string` | FCM always; Web Push for visible notifications |
21
+ | `data` | `Record<string, unknown>` | no (required for Web Push `silent` / data-only) |
22
22
  | `icon` | `string` | no |
23
23
  | `ttl` | `number` | no |
24
24
  | `messageId` | `string` | no |
@@ -28,14 +28,27 @@ Pick the shape that matches your transport.
28
28
  | Field | Type | Required |
29
29
  | --- | --- | --- |
30
30
  | `subscription` | `PushSubscription` | yes |
31
+ | `urgency` | `"very-low" \| "low" \| "normal" \| "high"` | no — RFC 8030 header |
32
+ | `topic` | `string` | no — 1–32 printable ASCII collapse key |
33
+ | `badge` | `string` | no |
34
+ | `image` | `string` | no |
35
+ | `tag` | `string` | no |
36
+ | `actions` | `{ action, title, icon? }[]` | no |
37
+ | `requireInteraction` | `boolean` | no |
38
+ | `renotify` | `boolean` | no |
39
+ | `silent` | `boolean` | no — encrypt only `data` |
31
40
 
32
41
  A subscription contains `endpoint`, `keys.p256dh`, and `keys.auth`.
33
42
 
43
+ Omit `title` / `body` and pass `data` for a data-only Web Push (or set `silent: true`).
44
+
34
45
  ## FCM
35
46
 
36
47
  | Field | Type | Required |
37
48
  | --- | --- | --- |
38
49
  | `token` | `string` | yes |
50
+ | `title` | `string` | yes |
51
+ | `body` | `string` | yes |
39
52
  | `image` | `string` | no |
40
53
 
41
54
  FCM stringifies non-string `data` values before send.
@@ -58,18 +71,22 @@ Map any channel result with [channel send result](./channel-result).
58
71
  <Accordion title="Can I send both subscription and token?">
59
72
  No. Each call is either Web Push (`subscription`) or FCM (`token`). Match the shape to the transport.
60
73
  </Accordion>
74
+ <Accordion title="Can FCM send silent / data-only the same way?">
75
+ No. FCM still requires `title` and `body` on this union. Use Web Push `silent` / data-only for background browser sync.
76
+ </Accordion>
61
77
  </Accordions>
62
78
 
63
79
  ## Learn more
64
80
 
65
81
  - [Push channel](/docs/channels/push)
66
- - [Stability policy](/docs/get-started/stability) — union shape is frozen at 1.x
82
+ - [Web Push transport](/docs/transports/webpush)
83
+ - [Stability policy](/docs/get-started/stability) — optional fields may grow in 1.x
67
84
  - [Channel send result](./channel-result)
68
85
 
69
86
  ## Next
70
87
 
71
88
  <Cards>
72
89
  <Card title="Push channel" href="/docs/channels/push" />
73
- <Card title="FCM transport" href="/docs/transports/fcm" />
90
+ <Card title="Web Push transport" href="/docs/transports/webpush" />
74
91
  <Card title="Channel send result" href="/docs/reference/channel-result" />
75
92
  </Cards>